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为什么必须在这里补一刀: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供应商、名称这类文本字段无法做累加/扣减,反写语义只有「用目标单最新值覆盖源单」。 + * 正向时把目标单原值写到源单并记下反写前原值;回滚时按记录的原值恢复。
+ * + * @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逐对处理关联关系:目标分录行的值作为「本次值」,源分录行的值作为「原值」,
@@ -459,43 +505,62 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
*/
private void applyFormula(ReverseWritingFormula formula, List 反写公式两种引用的行 id 来源不同:引用表头表时用单据 id,引用分录表时用分录行 id。
+ * 表头表在 onl_form_head 里有登记,分录表没有。 ORM 插入分录时会重新发号,关系表必须存真实主键,否则反写按 targe_entry_id 查不到行。 为什么需要:转换关系表 {@code convert_relation} / {@code convert_relation_entry} 只在
+ * BOTP 转换的 autoSave 分支写入。当 {@code formStatus=2}(未落库、由前端手动保存)时,
+ * 转换阶段不会写关系,保存接口落库后必须补写,否则后续「保存即反写」「上查下查」都找不到来源单。 为什么放在 common-core:与 {@link BotpReverseWriteFacade} 同理——补写实现与实体都在
+ * common-botp,而补写时机在 common-online,直接依赖会成环。 调用方应通过 {@link BotpReverseWriteSupport#saveRelationQuietly} 调用,未挂载 botp 时静默跳过。 已存在的关联关系(按关联关系主键判重)直接跳过,保证重复保存不会产生重复关系。 用于 {@code formStatus=2}(未落库、前端手动保存)的场景:转换阶段没写 convert_relation,
+ * 保存落库后由这里补上,否则后续反写找不到来源单。 ApplicationContextHolder 没有安全取法,这里同时兜住两类异常:容器里没有该 bean
@@ -61,8 +82,19 @@ public final class BotpReverseWriteSupport {
* @return 门面实现;未挂载时为 null。
*/
private static BotpReverseWriteFacade getFacadeQuietly() {
+ return getFacadeQuietly(BotpReverseWriteFacade.class);
+ }
+
+ /**
+ * 按类型安全取门面实现。
+ *
+ * @param facadeType 门面接口类型。
+ * @param