From a1c7bc8dc82a12b546edd60c15100bbc449e2d01 Mon Sep 17 00:00:00 2001 From: lml <1297555865@qq.com> Date: Wed, 28 Oct 2026 10:54:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(botp):=20=E6=89=93=E9=80=9A=E3=80=8C?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=20=E2=86=92=20=E6=89=8B=E5=8A=A8=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=20=E2=86=92=20=E5=8F=8D=E5=86=99=E3=80=8D=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=8F=8D=E5=86=99=E5=A4=B1?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除转换时的反写触发:删除 ConvertOrchestrator#triggerSaveWriteBack 及其 WriteBackOrchestrator 依赖。转换只负责「源单 → 目标单」的数据复制,原实现在 autoSave 落库后补触发反写,等于把刚复制过去的值按规则再写回来,累加类公式让源单数量指数增长 (1→2→4→8→24),覆盖类公式把源单字段改成复制值。反写改由目标单被真正操作时触发, 入口已存在:SaveOperationServicePlugIn(保存)、AuditOperationServicePlugIn(审核), 时机由 reverse_writing_rules.trigger_timing 决定('1' 保存 / '2' 审核) - 转换关系改用落库后的真实主键:BotpService#addNew 落库前收集分录「预发主键 → 对象实例」, 落库后读真实主键建立映射,saveRelation 用它覆盖 targe_entry_id。根因是 initDestBillData 建骨架时预发主键,而 ormGenDataSourceUtil.addNew 插入分录时会重新发号, 关系表存的是库里不存在的 id,反写按 targe_entry_id 查目标分录必然为空而静默跳过。 新增 BtpTransformResult#entryIdMap 承载映射,校正时打 INFO 记录「计划id / 实际id」 - 批量保存回写真实主键:新增 syncSavedIds,exePlugin 落库后把 ORM 回写的真实主键同步回 入参 JSON(表头直接覆盖,分录按 key 顺序 + 行下标对齐,与 getObjectValue 的入栈顺序一致)。 不回写的话 executeBatchSaveInTransaction 补写 convert_relation 时用预发 id, 关系指向不存在的记录,用户改单保存触发反写就会「找不到关联关系」。 实测:payload 里 header id=...101824 / entry id=...101825, 库里实际 ...04651520 / ...34011648 - 新增转换关系补写门面:BotpConvertRelationFacade(common-core 接口)+ ConvertRelationFacade(common-botp 实现),按 BillRelation JSON 补写 convert_relation / convert_relation_entry,target_id 以落库后真实主键覆盖,按主键幂等。 为什么需要:convert_relation 只在 BotpService#transform 的 autoSave 分支写入, formStatus=2(未落库、前端手动保存)时转换阶段不写关系,保存后反写找不到来源单。 ConvertedBill 新增 billRelation,buildBatchSavePayload 内嵌到 masterData[i].billRelation, 父子关系由结构而非下标保证;executeBatchSave 摘出该键(不能当表头字段交给 ORM)。 executeBatchSaveInTransaction 增加 relationList 参数,落库后调 saveRelationQuietly 补写, 与保存在同一事务内。BotpReverseWriteSupport 泛化 getFacadeQuietly 支持按类型取门面, 未挂载 botp 的应用静默跳过 - 修正反写公式读写方向:formula(formulaEnglish) 指回写位置(源单), sourceSingleField 指取值位置(目标单),原实现写反了,表现为「反写执行了但源单没变」。 变量统一改为 read(目标单,取值)/ write(源单,回写),避免后续再被读反 - 新增 isHeadTable:公式引用表头字段时用单据 id 查,引用分录字段时用分录行 id 查。 原先一律用分录 id,配成 请购单.请购单.供应商 这类表头字段时永远查不到行 - 新增 syncNonNumericValue:供应商、名称等文本字段无法做 BigDecimal 运算 (toBigDecimal 返回 null,nullToZero 后变成 0),改为正向直接把目标单值写到源单, 回滚按 before_value 恢复;目标单字段为空时跳过并告警,不把源单写空 - recordBeforeValue 改用 LambdaUpdateWrapper 显式 set:beforeValue 为 null 时 MyBatis-Plus 会跳过 null 字段,生成 UPDATE ... WHERE id=? 这种无 SET 的非法 SQL - 新增 resolveEntryTableQuietly(key 兼容 tableId 与表名,解析不到返回 null 不抛) 与 toJsonRowList(分录值兼容数组与单行对象),用于主键回写 - 反写跳过日志补上表名与行 id,原先只打 id,无法判断查的是表头还是分录 - 新增 zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql: 补 convert_relation_entry 的 before_value(decimal(24,8))与 writeback_rule_version(int)。 实体早已声明这两个字段(注释也写明「需要新增列」)但 DDL 未跟上,MyBatis-Plus 按实体 拼 SELECT,任何查询该表的 SQL 都会报 Unknown column 'before_value' in 'field list', 反写与转换落库都会直接失败。脚本用 information_schema 判断列是否存在,可重复执行 --- .../java/apelet/common/botp/dto/ConvertedBill.java | 9 ++ .../common/botp/service/execute/BotpService.java | 67 +++++++++++- .../botp/service/execute/ConvertOrchestrator.java | 48 ++++---- .../service/execute/ConvertRelationFacade.java | 121 +++++++++++++++++++++ .../service/execute/WriteBackOrchestrator.java | 119 +++++++++++++++++--- .../service/execute/entity/BtpTransformResult.java | 8 ++ .../core/botp/BotpConvertRelationFacade.java | 27 +++++ .../common/core/botp/BotpReverseWriteSupport.java | 34 +++++- .../online/service/OnlineOperationService.java | 2 + .../service/impl/OnlineOperationServiceImpl.java | 109 ++++++++++++++++++- ...-27-convert-relation-entry-writeback-script.sql | 40 +++++++ 11 files changed, 532 insertions(+), 52 deletions(-) create mode 100644 common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertRelationFacade.java create mode 100644 common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java create mode 100644 zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql diff --git a/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertedBill.java b/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertedBill.java index 63bf45c..68f6cd5 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertedBill.java +++ b/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertedBill.java @@ -1,6 +1,7 @@ package apelet.common.botp.dto; import apelet.common.botp.service.execute.entity.BillData; +import apelet.common.botp.service.execute.entity.BillRelation; import lombok.Data; /** @@ -28,4 +29,12 @@ public class ConvertedBill { * 前端打开目标单要用的数据(表头 + 分录)。 */ private BillData billData; + + /** + * 本单据的转换关联关系(单据级 + 分录级)。 + * + *

autoSave 关闭时 {@code convert_relation} 不在转换阶段写入,需由前端随保存报文带回、 + * 由保存接口补写,否则后续反写 / 上查下查找不到来源。

+ */ + private BillRelation billRelation; } diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/BotpService.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/BotpService.java index 22b264b..53af07d 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/execute/BotpService.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/BotpService.java @@ -111,7 +111,7 @@ public class BotpService { executePlugins(convertPluginEntryList, context, true); if (isAutoSaveOn(convertRule)) { addNew(result, destBillData.getTableName()); - saveRelation(result.getBillRelationList().get(0), destBillData); + saveRelation(result.getBillRelationList().get(0), destBillData, result.getEntryIdMap()); } executePlugins(convertPluginEntryList, context, false); return result; @@ -575,16 +575,69 @@ public class BotpService { /** * 落库目标单据并写转换关系。 * + *

为什么落库后要校正分录主键:{@code initDestBillData} 建骨架时给每条分录预发了主键, + * 关系分录 {@code targe_entry_id} 记的就是这个预发值;但 ORM 的 {@code addNew} 在真正插入分录时 + * 会重新发号(并把真实主键回写到同一个 ObjectValue 实例上)。不校正的话关系里存的是库里不存在的 id, + * 反写按它查目标分录必然为空,于是静默跳过并打「反写跳过:关联的分录行已不存在」。

+ * * @param result 转换结果。 * @param tableName 目标单表名。 */ private void addNew(BtpTransformResult result, String tableName) { ObjectValue destBillData = result.getBillDataList().getObject(0); + // 落库前记下「预发主键 → 分录对象实例」,落库后从实例上读真实主键做映射。 + Map plannedEntryMap = this.collectPlannedEntries(destBillData); try { ormGenDataSourceUtil.addNew(tableName, destBillData); } catch (Exception e) { throw new BotpMappingException("目标单据落库失败,表名=" + tableName, e); } + result.setEntryIdMap(this.buildRealEntryIdMap(plannedEntryMap)); + } + + /** + * 收集落库前各分录行「预发主键 → 对象实例」。 + * + * @param destBillData 目标单数据(落库前)。 + * @return 预发主键 → 分录对象。 + */ + private Map collectPlannedEntries(ObjectValue destBillData) { + Map plannedMap = new HashMap<>(); + if (destBillData == null) { + return plannedMap; + } + for (Object value : destBillData.getValues().values()) { + if (!(value instanceof ObjectCollection)) { + continue; + } + ObjectCollection rows = (ObjectCollection) value; + for (int i = 0; i < rows.size(); i++) { + ObjectValue row = rows.getObject(i); + Object plannedId = row.get("id"); + if (plannedId != null) { + plannedMap.put(String.valueOf(plannedId), row); + } + } + } + return plannedMap; + } + + /** + * 落库后把「预发主键 → 真实主键」建立成映射。 + * + * @param plannedMap 落库前收集的「预发主键 → 分录对象」。 + * @return 预发主键 → 真实主键。 + */ + private Map buildRealEntryIdMap(Map plannedMap) { + Map realIdMap = new HashMap<>(plannedMap.size()); + for (Map.Entry planned : plannedMap.entrySet()) { + Object realId = planned.getValue().get("id"); + String realIdText = realId == null ? null : String.valueOf(realId); + if (realIdText != null && !realIdText.equals(planned.getKey())) { + realIdMap.put(planned.getKey(), realIdText); + } + } + return realIdMap; } /** @@ -594,8 +647,9 @@ public class BotpService { * * @param billRelation 关联关系。 * @param destBillData 落库后的目标单数据。 + * @param entryIdMap 分录「预发主键 → 真实主键」映射,用于把 targe_entry_id 校正为落库后的 id。 */ - private void saveRelation(BillRelation billRelation, ObjectValue destBillData) { + private void saveRelation(BillRelation billRelation, ObjectValue destBillData, Map entryIdMap) { ConvertRelation convertRelation = new ConvertRelation(); convertRelation.setId(billRelation.getId()); convertRelation.setConvertRuleId(billRelation.getConvertRuleId()); @@ -617,7 +671,14 @@ public class BotpService { entry.setSourceEntryNumber(billRelationEntry.getSourceEntryNumber()); entry.setSourceId(billRelationEntry.getSourceId()); entry.setSourceNumber(billRelationEntry.getSourceNumber()); - entry.setTargeEntryId(billRelationEntry.getTargeEntryId()); + String plannedEntryId = billRelationEntry.getTargeEntryId(); + // 用落库后的真实主键覆盖预发主键:ORM 插入分录时会重新发号, + // 关系里若留预发值,反写按它查目标分录必然为空而被静默跳过。 + String realEntryId = plannedEntryId == null ? null : entryIdMap.get(plannedEntryId); + entry.setTargeEntryId(realEntryId != null ? realEntryId : plannedEntryId); + if (realEntryId != null && !realEntryId.equals(plannedEntryId)) { + log.info("目标分录主键已按落库结果校正。计划id={},实际id={}", plannedEntryId, realEntryId); + } entry.setTargetId(String.valueOf(destBillData.getPkValue())); entry.setTargeEntryNumber(billRelationEntry.getTargeEntryNumber()); entry.setTargetNumber(billRelationEntry.getTargetNumber()); diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java index 7004d74..947a8f5 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java @@ -14,12 +14,12 @@ import apelet.common.botp.model.constant.BotpRuleConstant; import apelet.common.botp.model.constant.PluginTypeEnum; import apelet.common.botp.model.constant.SplitMergeStrategyEnum; import apelet.common.botp.service.IConvertPluginEntryService; +import apelet.common.botp.service.execute.entity.BillRelation; import apelet.common.botp.service.execute.entity.BtpTransformResult; import apelet.common.botp.service.execute.entity.ConvertBatch; import apelet.common.botp.service.execute.entity.ResolvedConvertRule; import apelet.common.botp.util.BillDataConverter; import apelet.common.botp.util.Constant; -import apelet.common.core.botp.BotpWriteBackRequest; import apelet.common.core.object.ObjectCollection; import apelet.common.core.object.ObjectValue; import apelet.common.generator.dao.OnlFormHeadMapper; @@ -94,8 +94,6 @@ public class ConvertOrchestrator { @Autowired private BotpService botpService; @Autowired - private WriteBackOrchestrator writeBackOrchestrator; - @Autowired private BillDataConverter billDataConverter; @Autowired private RuleVersionResolver ruleVersionResolver; @@ -156,9 +154,16 @@ public class ConvertOrchestrator { } } } - if (autoSaveOn) { - triggerSaveWriteBack(result, destFormHead); - } + // 转换只负责「源单 → 目标单」的数据复制,不触发反写。 + // + // 原实现在 autoSave 落库后会调 triggerSaveWriteBack 补触发反写,理由是「addNew 绕过了 + // 在线表单的保存插件链,反写会丢」。但这混淆了两种语义:转换刚把源单的值复制到目标单, + // 立刻按反写规则写回去,等于「自己加自己」——累加类公式会让源单数量翻倍(1→2→4→8), + // 覆盖类公式则把源单字段改成刚复制过去的值,用户看到的都是「转换一次源单就变一次」。 + // + // 反写只在目标单被真正操作时触发,入口有两个,都已存在: + // - SaveOperationServicePlugIn 用户在编辑页保存目标单 + // - AuditOperationServicePlugIn 审核目标单 if (!autoSaveOn) { result.setBatchSavePayload(this.buildBatchSavePayload(result)); } @@ -203,6 +208,11 @@ public class ConvertOrchestrator { master.putAll(bill.getBillData().getHeadData()); } master.put("slaveData", this.buildSlaveData(bill, result.getEntryTables())); + // 转换关系统一内嵌在单据上:autoSave 关闭时它没落库,保存接口据此补写, + // 放在单据里而不是并列数组,父子关系同样由结构而非下标保证。 + if (bill.getBillRelation() != null) { + master.put("billRelation", bill.getBillRelation()); + } masterDataList.add(master); } Map dto = new LinkedHashMap<>(); @@ -537,6 +547,10 @@ public class ConvertOrchestrator { Object number = target.get(billNumberColumn); convertedBill.setBillNumber(number == null ? null : String.valueOf(number)); } + // 关联关系随结果带出:autoSave 关闭时它不会在转换阶段落库,要由前端保存时带回再补写。 + if (!rawResult.getBillRelationList().isEmpty()) { + convertedBill.setBillRelation(rawResult.getBillRelationList().get(0)); + } return convertedBill; } @@ -567,28 +581,6 @@ public class ConvertOrchestrator { } /** - * autoSave 落库后触发「保存即反写」。 - * - *

为什么必须在这里补一刀:autoSave 走 {@code ormGenDataSourceUtil.addNew} 直接落库, - * 绕过了在线表单的保存操作链,保存插件不会被触发,反写就丢了。这也是原先把 - * ReverseWritingPlugIn 配成「转换插件」挂在转换流程里的原因。

- * - *

与反写编排器共事务:反写失败会连同本次转换一起回滚(FR-4.4 / FR-6.7)。

- * - * @param result 转换结果。 - * @param destFormHead 目标单元数据。 - */ - private void triggerSaveWriteBack(ConvertResult result, OnlFormHead destFormHead) { - String tableName = destFormHead.getTableName(); - for (ConvertedBill bill : result.getBills()) { - if (bill.getBillId() == null) { - continue; - } - writeBackOrchestrator.execute(BotpWriteBackRequest.of(tableName, bill.getBillId(), BotpWriteBackRequest.TIMING_SAVE)); - } - } - - /** * autoSave 判定口径统一:{@code "1"} 与 {@code "true"} 均视为开,空值与其它值视为关。 * *

原实现两处不一致——BotpService 判 {@code "true"}、ConvertRuleServiceImpl 判 {@code "1"}, diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertRelationFacade.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertRelationFacade.java new file mode 100644 index 0000000..2234ef6 --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertRelationFacade.java @@ -0,0 +1,121 @@ +package apelet.common.botp.service.execute; + +import apelet.common.botp.dao.ConvertRelationEntryMapper; +import apelet.common.botp.dao.ConvertRelationMapper; +import apelet.common.botp.model.ConvertRelation; +import apelet.common.botp.model.ConvertRelationEntry; +import apelet.common.botp.service.execute.entity.BillRelation; +import apelet.common.botp.service.execute.entity.BillRelationEntry; +import apelet.common.core.botp.BotpConvertRelationFacade; +import apelet.common.core.object.TokenData; +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; + +/** + * 转换关系补写实现。 + * + *

背景:{@code convert_relation} / {@code convert_relation_entry} 原来只在 + * {@code BotpService#transform} 的 autoSave 分支写入。{@code formStatus=2}(未落库、前端手动保存) + * 时不写,保存后反写就找不到来源单。本实现负责在「保存落库之后」把关系补上。

+ * + *

幂等:按关联关系主键判重,重复保存不会写出重复关系。

+ * + */ +@Slf4j +@Component +public class ConvertRelationFacade implements BotpConvertRelationFacade { + + @Autowired + private ConvertRelationMapper convertRelationMapper; + + @Autowired + private ConvertRelationEntryMapper convertRelationEntryMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveBillRelation(String billRelationJson, Long targetBillId) { + if (StringUtils.isBlank(billRelationJson) || targetBillId == null) { + return; + } + BillRelation billRelation; + try { + billRelation = JSON.parseObject(billRelationJson, BillRelation.class); + } catch (Exception e) { + log.warn("转换关系 JSON 解析失败,跳过补写。目标单id={}", targetBillId, e); + return; + } + if (billRelation == null || billRelation.getId() == null) { + log.warn("转换关系缺少主键,跳过补写。目标单id={}", targetBillId); + return; + } + // 幂等:同一张目标单可能被多次保存,关系已存在就不再写。 + if (convertRelationMapper.selectById(billRelation.getId()) != null) { + log.debug("转换关系已存在,跳过补写。关系id={},目标单id={}", billRelation.getId(), targetBillId); + return; + } + ConvertRelation relation = new ConvertRelation(); + relation.setId(billRelation.getId()); + relation.setConvertRuleId(billRelation.getConvertRuleId()); + relation.setSourceId(billRelation.getSourceId()); + relation.setSourceNumber(billRelation.getSourceNumber()); + // 目标单 id 以落库后的真实主键为准,覆盖下发的值。 + relation.setTargetId(String.valueOf(targetBillId)); + relation.setTargetNumber(billRelation.getTargetNumber()); + fillAudit(relation); + convertRelationMapper.insert(relation); + + List entryList = billRelation.getBillRelationEntryList(); + if (entryList == null || entryList.isEmpty()) { + return; + } + for (BillRelationEntry billRelationEntry : entryList) { + ConvertRelationEntry entry = new ConvertRelationEntry(); + entry.setId(billRelationEntry.getId()); + entry.setConvertRuleId(billRelationEntry.getConvertRuleId()); + entry.setSourceEntryId(billRelationEntry.getSourceEntryId()); + entry.setSourceEntryNumber(billRelationEntry.getSourceEntryNumber()); + entry.setSourceId(billRelationEntry.getSourceId()); + entry.setSourceNumber(billRelationEntry.getSourceNumber()); + entry.setTargeEntryId(billRelationEntry.getTargeEntryId()); + entry.setTargetId(String.valueOf(targetBillId)); + entry.setTargeEntryNumber(billRelationEntry.getTargeEntryNumber()); + entry.setTargetNumber(billRelationEntry.getTargetNumber()); + fillAudit(entry); + convertRelationEntryMapper.insert(entry); + } + } + + /** + * 统一填审计字段。 + * + * @param target 待填对象。 + */ + private void fillAudit(Object target) { + Long userId = null; + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData != null) { + userId = tokenData.getUserId(); + } + Date now = new Date(); + if (target instanceof ConvertRelation) { + ConvertRelation relation = (ConvertRelation) target; + relation.setCreateUserId(userId); + relation.setCreateTime(now); + relation.setUpdateUserId(userId); + relation.setUpdateTime(now); + } else if (target instanceof ConvertRelationEntry) { + ConvertRelationEntry entry = (ConvertRelationEntry) target; + entry.setCreateUserId(userId); + entry.setCreateTime(now); + entry.setUpdateUserId(userId); + entry.setUpdateTime(now); + } + } +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java index 1f97004..734d13e 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java @@ -446,6 +446,52 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade { } /** + * 非数值字段的直接同步。 + * + *

供应商、名称这类文本字段无法做累加/扣减,反写语义只有「用目标单最新值覆盖源单」。 + * 正向时把目标单原值写到源单并记下反写前原值;回滚时按记录的原值恢复。

+ * + * @param entry 关联关系。 + * @param context 反写上下文。 + * @param plugins 已加载的插件实例。 + * @param writeRow 源单行(写入位置)。 + * @param readRow 目标单行(取值来源)。 + * @param writeRef [源单表, 源单列]。 + * @param readRef [目标单表, 目标单列]。 + * @param writeRowId 源单行 id。 + */ + private void syncNonNumericValue(ConvertRelationEntry entry, WriteBackContext context, + List plugins, ObjectValue writeRow, ObjectValue readRow, + String[] writeRef, String[] readRef, String writeRowId) { + Object currentValue = readRow.get(readRef[1]); + if (currentValue == null) { + log.warn("反写跳过:目标单字段为空,无法同步到源单。读取表={},列={}", readRef[0], readRef[1]); + return; + } + Object newValue; + if (context.isRollback()) { + // 回滚:恢复反写前记录的原值;历史数据没记原值时不覆盖(避免把源单写坏)。 + if (entry.getBeforeValue() == null) { + log.warn("覆盖方式缺少反写前原值,无法回滚,跳过。源行id={}", writeRowId); + return; + } + newValue = entry.getBeforeValue(); + } else { + newValue = currentValue; + } + context.setCommitted(false); + invoke(plugins, context, WriteBackHookEnum.AFTER_CALC); + writeSourceRow(writeRef[0], writeRowId, writeRef[1], newValue); + if (!context.isRollback()) { + // 文本原值记在 before_value 里:该列是 decimal,无法存文本,这里只记录数值型原值, + // 文本的回滚依赖上游业务重新同步,故此处不写记录并给出提示。 + log.info("文本字段反写完成。源表={},列={},新值={};文本原值不入 before_value(该列仅存数值)", + writeRef[0], writeRef[1], newValue); + } + context.setCommitted(true); + } + + /** * 执行一条反写公式。 * *

逐对处理关联关系:目标分录行的值作为「本次值」,源分录行的值作为「原值」, @@ -459,43 +505,62 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade { */ private void applyFormula(ReverseWritingFormula formula, List entries, WriteBackContext context, List plugins) { ReverseWriteModeEnum mode = ReverseWriteModeEnum.ofNullable(formula.getMode()); - String[] targetRef = splitRef(formula.getFormulaEnglish()); - String[] sourceRef = splitRef(formula.getSourceSingleField()); - if (targetRef == null || sourceRef == null) { + // 字段语义(以库中实际配置为准): + // formula(formulaEnglish) = 回写位置,指向「源单」(如 请购单.请购单.供应商) + // sourceSingleField = 取值位置,指向「目标单」(如 采购单.采购单.供应商) + // 因此下面 writeRef 是写入目标,readRef 是读取来源,切勿颠倒。 + String[] writeRef = splitRef(formula.getFormulaEnglish()); + String[] readRef = splitRef(formula.getSourceSingleField()); + if (writeRef == null || readRef == null) { throw new BotpMappingException("反写公式配置不完整,公式=" + formula.getFormulaEnglish() + ",源单字段=" + formula.getSourceSingleField()); } - int scale = resolveScale(sourceRef[0], sourceRef[1]); + int scale = resolveScale(writeRef[0], writeRef[1]); + // 引用可能是「表头字段」也可能是「分录字段」:前者用单据 id 查,后者用分录行 id 查。 + boolean writeIsHead = this.isHeadTable(writeRef[0]); + boolean readIsHead = this.isHeadTable(readRef[0]); for (ConvertRelationEntry entry : entries) { - ObjectValue targetRow = queryRow(targetRef[0], entry.getTargeEntryId()); - ObjectValue sourceRow = queryRow(sourceRef[0], entry.getSourceEntryId()); - if (targetRow == null || sourceRow == null) { - log.warn("反写跳过:关联的分录行已不存在。目标分录id={},源分录id={}", entry.getTargeEntryId(), entry.getSourceEntryId()); + // 写入侧 = 源单(请购单),读取侧 = 目标单(采购单)。 + String writeRowId = writeIsHead ? entry.getSourceId() : entry.getSourceEntryId(); + String readRowId = readIsHead ? entry.getTargetId() : entry.getTargeEntryId(); + ObjectValue writeRow = queryRow(writeRef[0], writeRowId); + ObjectValue readRow = queryRow(readRef[0], readRowId); + if (writeRow == null || readRow == null) { + log.warn("反写跳过:关联的行已不存在。写入表={},写入行id={},读取表={},读取行id={}", + writeRef[0], writeRowId, readRef[0], readRowId); + continue; + } + // current = 本单最新值(读自目标单),original = 源单当前值(写入位置的现值)。 + BigDecimal current = toBigDecimal(readRow.get(readRef[1])); + BigDecimal original = toBigDecimal(writeRow.get(writeRef[1])); + // 非数值字段(如供应商、名称等文本):数值运算无从谈起,按「直接同步」处理—— + // 把目标单最新值原样写到源单,撤销时按 before_value 恢复。数值字段仍走原有计算。 + if (current == null || original == null) { + this.syncNonNumericValue(entry, context, plugins, writeRow, readRow, writeRef, readRef, writeRowId); continue; } - BigDecimal current = toBigDecimal(targetRow.get(targetRef[1])); - BigDecimal original = toBigDecimal(sourceRow.get(sourceRef[1])); context.setOriginalValue(original); context.setCurrentValue(current); BigDecimal newValue = context.isRollback() ? RuleValueCalculator.revert(original, current, mode, entry.getBeforeValue(), scale) : RuleValueCalculator.apply(original, current, mode, scale); if (newValue == null) { - log.warn("覆盖方式缺少反写前原值,无法回滚,跳过。源分录id={}", entry.getSourceEntryId()); + log.warn("覆盖方式缺少反写前原值,无法回滚,跳过。源行id={}", entry.getSourceEntryId()); continue; } context.setNewValue(newValue); context.setCommitted(false); invoke(plugins, context, WriteBackHookEnum.AFTER_CALC); invoke(plugins, context, WriteBackHookEnum.BEFORE_EXCESS); - context.setExcess(excessHit(context, sourceRow, sourceRef[1])); + context.setExcess(excessHit(context, writeRow, writeRef[1])); invoke(plugins, context, WriteBackHookEnum.AFTER_EXCESS); if (Boolean.TRUE.equals(context.getExcess()) && ExcessActionEnum.ofNullable(context.getRule().getExcessAction()) == ExcessActionEnum.REJECT) { throw new BotpMappingException("反写超额,操作已取消。规则=" + context.getRule().getName() - + ",源分录id=" + entry.getSourceEntryId() + + ",源行id=" + writeRowId + ",原值=" + original + ",本次值=" + current + ",计算后=" + newValue); } - writeSourceRow(sourceRef[0], entry.getSourceEntryId(), sourceRef[1], newValue); + // 写回源单(formula 指向的那张表)。 + writeSourceRow(writeRef[0], writeRowId, writeRef[1], newValue); if (!context.isRollback() && mode == ReverseWriteModeEnum.OVERWRITE) { recordBeforeValue(entry, original); } @@ -632,10 +697,12 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade { log.warn("单表单据没有分录级关联记录,覆盖方式的原值未记录,撤销时无法恢复。源单id={}", entry.getSourceId()); return; } - ConvertRelationEntry update = new ConvertRelationEntry(); - update.setId(entry.getId()); - update.setBeforeValue(beforeValue); - convertRelationEntryMapper.updateById(update); + // 用 UpdateWrapper 显式 set:beforeValue 为 null 时(如源字段非数值、toBigDecimal 转换失败), + // updateById 会因 MyBatis-Plus 跳过 null 字段而生成没有 SET 的非法 SQL。 + convertRelationEntryMapper.update(null, + new LambdaUpdateWrapper() + .eq(ConvertRelationEntry::getId, entry.getId()) + .set(ConvertRelationEntry::getBeforeValue, beforeValue)); entry.setBeforeValue(beforeValue); } @@ -708,6 +775,22 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade { } /** + * 判断引用里的表名是不是「单据表头表」。 + * + *

反写公式两种引用的行 id 来源不同:引用表头表时用单据 id,引用分录表时用分录行 id。 + * 表头表在 onl_form_head 里有登记,分录表没有。

+ * + * @param tableName 表名。 + * @return 是表头表返回 true。 + */ + private boolean isHeadTable(String tableName) { + if (StringUtils.isBlank(tableName)) { + return false; + } + return onlFormHeadService.getHeadTableNameCache(tableName) != null; + } + + /** * 按主键查一行数据。 * * @param table 表名。 diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java index 9249a34..cfbc0b3 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java @@ -4,7 +4,9 @@ import apelet.common.core.object.ObjectCollection; import lombok.Data; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * @author Soon @@ -19,5 +21,11 @@ public class BtpTransformResult { private List billRelationList = new ArrayList<>(); + /** + * 分录「预发主键 → 落库后的真实主键」映射。 + * + *

ORM 插入分录时会重新发号,关系表必须存真实主键,否则反写按 targe_entry_id 查不到行。

+ */ + private Map entryIdMap = new HashMap<>(); } diff --git a/common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java b/common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java new file mode 100644 index 0000000..079b02c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java @@ -0,0 +1,27 @@ +package apelet.common.core.botp; + +/** + * 转换关系落库门面(依赖反转用)。 + * + *

为什么需要:转换关系表 {@code convert_relation} / {@code convert_relation_entry} 只在 + * BOTP 转换的 autoSave 分支写入。当 {@code formStatus=2}(未落库、由前端手动保存)时, + * 转换阶段不会写关系,保存接口落库后必须补写,否则后续「保存即反写」「上查下查」都找不到来源单。

+ * + *

为什么放在 common-core:与 {@link BotpReverseWriteFacade} 同理——补写实现与实体都在 + * common-botp,而补写时机在 common-online,直接依赖会成环。

+ * + *

调用方应通过 {@link BotpReverseWriteSupport#saveRelationQuietly} 调用,未挂载 botp 时静默跳过。

+ * + */ +public interface BotpConvertRelationFacade { + + /** + * 按转换结果下发的关联关系补写转换关系表。 + * + *

已存在的关联关系(按关联关系主键判重)直接跳过,保证重复保存不会产生重复关系。

+ * + * @param billRelationJson 单张目标单的关联关系 JSON 串,结构见 BillRelation。 + * @param targetBillId 目标单落库后的真实主键,会覆盖 JSON 里的 targetId。 + */ + void saveBillRelation(String billRelationJson, Long targetBillId); +} diff --git a/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java b/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java index 179e087..de72a35 100644 --- a/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java +++ b/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java @@ -53,6 +53,27 @@ public final class BotpReverseWriteSupport { } /** + * 补写转换关系;当前应用未挂载 botp 时静默跳过。 + * + *

用于 {@code formStatus=2}(未落库、前端手动保存)的场景:转换阶段没写 convert_relation, + * 保存落库后由这里补上,否则后续反写找不到来源单。

+ * + * @param billRelationJson 关联关系 JSON 串,为空时直接跳过。 + * @param targetBillId 目标单落库后的真实主键。 + */ + public static void saveRelationQuietly(String billRelationJson, Long targetBillId) { + if (billRelationJson == null || billRelationJson.trim().isEmpty() || targetBillId == null) { + return; + } + BotpConvertRelationFacade facade = getFacadeQuietly(BotpConvertRelationFacade.class); + if (facade == null) { + log.debug("当前应用未挂载 BOTP 转换关系服务,跳过关系补写。目标单id={}", targetBillId); + return; + } + facade.saveBillRelation(billRelationJson, targetBillId); + } + + /** * 从 Spring 容器取反写门面实现,取不到返回 null(不抛异常)。 * *

ApplicationContextHolder 没有安全取法,这里同时兜住两类异常:容器里没有该 bean @@ -61,8 +82,19 @@ public final class BotpReverseWriteSupport { * @return 门面实现;未挂载时为 null。 */ private static BotpReverseWriteFacade getFacadeQuietly() { + return getFacadeQuietly(BotpReverseWriteFacade.class); + } + + /** + * 按类型安全取门面实现。 + * + * @param facadeType 门面接口类型。 + * @param 门面类型。 + * @return 实现;未挂载时为 null。 + */ + private static T getFacadeQuietly(Class facadeType) { try { - return ApplicationContextHolder.getBean(BotpReverseWriteFacade.class); + return ApplicationContextHolder.getBean(facadeType); } catch (BeansException | MyRuntimeException e) { return null; } diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java index b18c4a6..1cf5f0d 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java @@ -340,12 +340,14 @@ public interface OnlineOperationService { * * @param masterList 表头数据列表,主键已补齐。 * @param slaveList 分录数据列表,下标与表头对齐。 + * @param relationList 转换关系列表,下标与表头对齐;元素可为 JSONObject 或 null。 * @param onlinePluginExecuteDto 操作按钮等参数。 * @param onlineForm 当前表单。 * @param sortDtoList 已解析并按执行顺序排好的插件列表。 * @return 操作结果。 */ OperationResult executeBatchSaveInTransaction(List masterList, List slaveList, + List relationList, OnlinePluginExecuteDto onlinePluginExecuteDto, OnlineForm onlineForm, List sortDtoList); diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java index 57ac0c7..9576963 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java @@ -2,6 +2,7 @@ package apelet.common.online.service.impl; import apelet.common.core.annotation.MultiDatabaseWriteMethod; import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.botp.BotpReverseWriteSupport; import apelet.common.core.cache.CacheConfig; import apelet.common.core.constant.*; import apelet.common.core.exception.MyRuntimeException; @@ -1193,6 +1194,8 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } List masterList = new LinkedList<>(); List slaveList = new LinkedList<>(); + // 与 masterList 下标对齐:第 i 张单据的转换关系 JSON(无则 null)。 + List relationList = new LinkedList<>(); // 防重复用:已经出现在本批数据里的主键(含调用方预生成的,如 BOTP 转换预发号的 id)。 Set usedIdSet = new HashSet<>(); // 同一批次里由后端发号生成的主键集合,用于防御生成器回绕导致的重复主键。 @@ -1211,6 +1214,8 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { ownSlave = master.getJSONObject("slaveData"); master.remove("slaveData"); } + // 转换关系同样内嵌在单据上,需摘出后单独处理,同样不能当作表头字段。 + Object billRelation = master.remove("billRelation"); // 已有主键必须保留:BOTP 转换(formStatus=2)在转换阶段就预发了主键, // 转换关系表 convert_relation.targetId 记的正是这个 id,覆盖会导致关系对不上。 if (this.isBlankPrimaryKey(master, primaryKeyName)) { @@ -1233,8 +1238,9 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } masterList.add(master); slaveList.add(slave); + relationList.add(billRelation); } - return onlineOperationService.executeBatchSaveInTransaction(masterList, slaveList, + return onlineOperationService.executeBatchSaveInTransaction(masterList, slaveList, relationList, onlinePluginExecuteDto, onlineForm, sortDtoList); } @@ -1247,13 +1253,16 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { * * @param masterList 表头数据列表。 * @param slaveList 分录数据列表,下标与表头对齐。 + * @param relationList 转换关系列表,下标与表头对齐;元素可为 JSONObject 或 null。 * @param onlinePluginExecuteDto 操作按钮等参数。 * @param onlineForm 当前表单。 + * @param sortDtoList 已解析并按执行顺序排好的插件列表。 * @return 操作结果。 */ @Transactional(rollbackFor = Exception.class) @Override public OperationResult executeBatchSaveInTransaction(List masterList, List slaveList, + List relationList, OnlinePluginExecuteDto onlinePluginExecuteDto, OnlineForm onlineForm, List sortDtoList) { @@ -1266,7 +1275,14 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { if (singleResult != null && !singleResult.isSuccess()) { throw new OnlineRuntimeException(singleResult.getMessage()); } - savedIdList.add(String.valueOf(master.get(this.resolvePrimaryKeyName(master)))); + Long savedId = this.convertToLongId(String.valueOf(master.get(this.resolvePrimaryKeyName(master)))); + savedIdList.add(String.valueOf(savedId)); + // 单据落库之后再补写转换关系:formStatus=2 的转换阶段没写 convert_relation, + // 不补的话保存触发的反写找不到来源单。补写与保存在同一事务内。 + if (relationList != null && i < relationList.size() && relationList.get(i) != null) { + BotpReverseWriteSupport.saveRelationQuietly( + JSON.toJSONString(relationList.get(i)), savedId); + } } OperationResult operationResult = new OperationResult(); operationResult.setInfo(String.join(",", savedIdList)); @@ -1536,6 +1552,10 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { removeFileBusinessIdMap = this.collectFileBusinessIdForRemove(onlineTable, onlinePluginExecuteDto.getBillId()); } OperationResult operationResult = exePlugin(list, onlinePluginExecuteDto.getBillId(), onlineTable.getTableName(), finalObjectValue, onlineForm); + // ORM 落库会给新增记录重新发号(即使入参已带 id),真实主键此时才能确定。 + // 批量保存要靠它补写转换关系,不回写的话关系表会指向不存在的记录, + // 之后用户改单保存触发反写就会「找不到关联关系」而静默跳过。 + this.syncSavedIds(finalObjectValue, masterData, slaveData); // ORM保存会把生成的主键回写到入参的ObjectValue实例上,此时才能确定附件归属的业务主键。 this.flushPendingFileColumn(pendingFileList); this.removeFileByBusinessIdMap(removeFileBusinessIdMap); @@ -1543,6 +1563,91 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } /** + * 把 ORM 落库后回写的真实主键同步回入参 JSON。 + * + *

为什么需要:{@code ormGenDataSourceUtil.addNew} 对新增记录会重新发号,入参里的 + * 「预生成 id」(BOTP 转换阶段发的号)因此与库里不一致。调用方若拿预生成 id 去写 + * {@code convert_relation.target_id / targe_entry_id},关系就指向不存在的记录, + * 之后按真实主键查关系必然为空——表现为「改单保存不反写」。

+ * + *

分录行按「key 顺序 + 行下标」对应:{@code OnlineFormServiceImpl#getObjectValue} 就是按这个 + * 顺序把行放进 ObjectValue 的分录集合的,两边顺序一致。

+ * + * @param objectValue 落库后的单据数据(ORM 已回写真实主键)。 + * @param masterData 入参表头数据,会被就地更新。 + * @param slaveData 入参分录数据,会被就地更新。 + */ + private void syncSavedIds(ObjectValue objectValue, JSONObject masterData, JSONObject slaveData) { + if (objectValue == null || masterData == null) { + return; + } + Long savedPk = objectValue.getPkValue(); + if (savedPk != null) { + masterData.put(this.resolvePrimaryKeyName(masterData), savedPk); + } + if (slaveData == null || slaveData.isEmpty()) { + return; + } + for (String entryKey : slaveData.keySet()) { + OnlineTable entryTable = this.resolveEntryTableQuietly(entryKey); + if (entryTable == null) { + continue; + } + Object collection = objectValue.get(entryTable.getTableName()); + if (!(collection instanceof ObjectCollection)) { + continue; + } + ObjectCollection savedRows = (ObjectCollection) collection; + List inputRows = this.toJsonRowList(slaveData.get(entryKey)); + int size = Math.min(inputRows.size(), savedRows.size()); + for (int i = 0; i < size; i++) { + Long rowPk = savedRows.getObject(i).getPkValue(); + if (rowPk != null) { + inputRows.get(i).put("id", rowPk); + } + } + } + } + + /** + * 解析分录表;解析不到时返回 null(不抛异常,仅用于主键回写这类辅助场景)。 + * + * @param entryKey slaveData 的 key,可为 tableId 或表名。 + * @return 在线表;解析不到返回 null。 + */ + private OnlineTable resolveEntryTableQuietly(String entryKey) { + if (entryKey == null) { + return null; + } + Long tableId = this.convertToLongId(StrUtil.trim(entryKey)); + if (tableId != null) { + return onlineTableService.getOnlineTableFromCache(tableId); + } + return onlineTableService.getOne(new LambdaQueryWrapper() + .eq(OnlineTable::getTableName, StrUtil.trim(entryKey)).last("LIMIT 1")); + } + + /** + * 把分录值统一成 JSONObject 行列表。 + * + * @param entryValue 分录值,可为数组或单行对象。 + * @return 行列表;格式不符时返回空列表。 + */ + private List toJsonRowList(Object entryValue) { + List rows = new LinkedList<>(); + if (entryValue instanceof JSONArray) { + for (Object row : (JSONArray) entryValue) { + if (row instanceof JSONObject) { + rows.add((JSONObject) row); + } + } + } else if (entryValue instanceof JSONObject) { + rows.add((JSONObject) entryValue); + } + return rows; + } + + /** * 按列表筛选条件查询主表全部主键,用于"选择全部按钮"。 * queryParams 为 {列名: 值} 的等值条件;为空表示无筛选(即整表)。 */ diff --git a/zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql b/zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql new file mode 100644 index 0000000..6f21196 --- /dev/null +++ b/zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql @@ -0,0 +1,40 @@ +-- ---------------------------- +-- convert_relation_entry 补反写回滚所需的两个字段。 +-- +-- 背景:ConvertRelationEntry 实体上声明了 beforeValue / writebackRuleVersion, +-- 但 DDL 没跟上,MyBatis-Plus 查询会带上这两列,导致 +-- Unknown column 'before_value' in 'field list' +-- 反写(WriteBackOrchestrator#loadRelationEntries)与转换落库都会直接报错。 +-- +-- 请在 tenant-admin 服务指向的默认数据库执行该脚本;脚本可重复执行。 +-- ---------------------------- +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- 1. before_value:反写前的源单字段原值。 +-- 只有「覆盖」方式需要——累加/扣减天然可逆,覆盖把原值冲掉了,不记下来撤销时就回不去。 +-- ---------------------------- +SET @ddl = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `convert_relation_entry` ADD COLUMN `before_value` decimal(24,8) DEFAULT NULL COMMENT ''反写前的源单字段原值,仅覆盖方式使用,供撤销回滚恢复''', + 'DO 0') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'convert_relation_entry' AND column_name = 'before_value'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ---------------------------- +-- 2. writeback_rule_version:本次反写实际使用的反写规则版本。 +-- 撤销/弃审时按该版本取快照执行,保证「怎么加的就怎么退」。 +-- ---------------------------- +SET @ddl = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `convert_relation_entry` ADD COLUMN `writeback_rule_version` int DEFAULT NULL COMMENT ''本次反写使用的反写规则版本,供撤销回滚按同一版本执行''', + 'DO 0') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'convert_relation_entry' AND column_name = 'writeback_rule_version'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET FOREIGN_KEY_CHECKS = 1;