diff --git a/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertResult.java b/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertResult.java index d867e33..989bc09 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertResult.java +++ b/common/common-botp/src/main/java/apelet/common/botp/dto/ConvertResult.java @@ -4,6 +4,7 @@ import lombok.Data; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * 转换结果。 @@ -73,4 +74,13 @@ public class ConvertResult { * executePluginBatch 入参 slaveData(key 为 tableId)。 */ private List entryTables = new ArrayList<>(); + + /** + * 可直接提交给 /onlineOperation/executePluginBatch 的完整报文。 + * + *

结构:{@code { onlinePluginExecuteDto: {formId, buttonName}, masterData: [{表头..., slaveData: {...}}] }}。 + * 只有 formStatus == "2"(未落库)且能确定目标编辑表单与其保存按钮时才产出,否则为 null, + * 前端此时按 {@link #entryTables} 自行组装。

+ */ + private Map batchSavePayload; } 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 233bbab..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,13 +154,161 @@ public class ConvertOrchestrator { } } } - if (autoSaveOn) { - triggerSaveWriteBack(result, destFormHead); + // 转换只负责「源单 → 目标单」的数据复制,不触发反写。 + // + // 原实现在 autoSave 落库后会调 triggerSaveWriteBack 补触发反写,理由是「addNew 绕过了 + // 在线表单的保存插件链,反写会丢」。但这混淆了两种语义:转换刚把源单的值复制到目标单, + // 立刻按反写规则写回去,等于「自己加自己」——累加类公式会让源单数量翻倍(1→2→4→8), + // 覆盖类公式则把源单字段改成刚复制过去的值,用户看到的都是「转换一次源单就变一次」。 + // + // 反写只在目标单被真正操作时触发,入口有两个,都已存在: + // - SaveOperationServicePlugIn 用户在编辑页保存目标单 + // - AuditOperationServicePlugIn 审核目标单 + if (!autoSaveOn) { + result.setBatchSavePayload(this.buildBatchSavePayload(result)); } return result; } /** + * 组装 executePluginBatch 的完整入参。 + * + *

每张单据的分录内嵌在自身 masterData 元素里({@code slaveData} 键),父子关系由结构而非下标保证; + * 分录 key 用 tableId(后端同时兼容表名)。

+ * + *

任何一环无法确定(非在线表单、缺编辑表单 id、找不到绑定保存插件的按钮)都返回 null, + * 由前端按 entryTables 自行组装,不做猜测。

+ * + * @param result 转换结果(需已填充 bills / entryTables / editFormId)。 + * @return 可直接提交的报文;无法组装时返回 null。 + */ + private Map buildBatchSavePayload(ConvertResult result) { + if (!Constant.InterfaceType_ONLINE.equals(result.getFormType())) { + return null; + } + if (result.getEditFormId() == null) { + log.warn("转换结果缺少目标编辑表单 id,不产出 batchSavePayload"); + return null; + } + String saveButtonName = this.resolveSaveButtonName(result.getEditFormId()); + if (StringUtils.isBlank(saveButtonName)) { + log.warn("目标编辑表单 [{}] 未找到绑定保存插件的按钮,不产出 batchSavePayload", result.getEditFormId()); + return null; + } + if (result.getBills().isEmpty()) { + return null; + } + List> masterDataList = new ArrayList<>(); + for (ConvertedBill bill : result.getBills()) { + if (bill.getBillData() == null) { + return null; + } + Map master = new LinkedHashMap<>(); + if (bill.getBillData().getHeadData() != null) { + 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<>(); + dto.put("formId", result.getEditFormId()); + dto.put("buttonName", saveButtonName); + Map payload = new LinkedHashMap<>(); + payload.put("onlinePluginExecuteDto", dto); + payload.put("masterData", masterDataList); + return payload; + } + + /** + * 把一张单据的分录按 tableId 汇总成 slaveData 结构。 + * + *

同一张表可能有多个关系(dataKey)指向它,这里做合并而不是覆盖,避免静默丢行。

+ * + * @param bill 目标单据。 + * @param entryTables 分录表信息。 + * @return slaveData:{tableId: [行...]}。 + */ + private Map buildSlaveData(ConvertedBill bill, List entryTables) { + Map slaveData = new LinkedHashMap<>(); + Map>> entryData = bill.getBillData().getEntryData(); + if (entryData == null || entryTables == null) { + return slaveData; + } + for (ConvertEntryTable entryTable : entryTables) { + // dataKey 是 BillData.entryData 里的 key(关系名),不对时按表名兜底再取一次。 + List> rows = entryData.get(entryTable.getDataKey()); + if (rows == null) { + rows = entryData.get(entryTable.getTableName()); + } + if (rows == null || rows.isEmpty()) { + continue; + } + String tableIdKey = String.valueOf(entryTable.getTableId()); + List> merged = new ArrayList<>(); + Object existing = slaveData.get(tableIdKey); + if (existing instanceof List) { + merged.addAll((List>) existing); + } + merged.addAll(rows); + slaveData.put(tableIdKey, merged); + } + return slaveData; + } + + /** + * 从目标编辑表单的 widgetJson 中找出绑定保存插件的按钮名。 + * + *

按插件类名精确匹配(与 executePluginBatch 的校验口径一致),不按按钮中文名猜。

+ * + * @param editFormId 目标编辑表单 id。 + * @return 按钮名;找不到返回 null。 + */ + private String resolveSaveButtonName(Long editFormId) { + OnlineForm editForm = onlineFormService.getOnlineFormFromCache(editFormId); + if (editForm == null || StringUtils.isBlank(editForm.getWidgetJson())) { + return null; + } + JSONObject widgetJson = JSON.parseObject(editForm.getWidgetJson()); + // 保存按钮的插件在 pc 与 mobile 下配置一致,取 pc 即可。 + JSONObject pc = widgetJson.getJSONObject("pc"); + if (pc == null) { + return null; + } + JSONArray operationList = pc.getJSONArray("operationList"); + if (operationList == null) { + return null; + } + for (int i = 0; i < operationList.size(); i++) { + JSONObject operation = operationList.getJSONObject(i); + if (!Boolean.TRUE.equals(operation.getBoolean("enabled"))) { + continue; + } + JSONArray pluginList = operation.getJSONArray("pluginList"); + if (pluginList == null) { + continue; + } + for (int j = 0; j < pluginList.size(); j++) { + JSONObject plugin = pluginList.getJSONObject(j); + if (Boolean.TRUE.equals(plugin.getBoolean("isEnable")) + && SAVE_PLUGIN_CLASS.equals(plugin.getString("class"))) { + return operation.getString("name"); + } + } + } + return null; + } + + /** + * 保存插件类名,与 executePluginBatch 的校验口径保持一致。 + */ + private static final String SAVE_PLUGIN_CLASS = "apelet.common.online.plugin.pre.SaveOperationServicePlugIn"; + + /** * 解析规则:显式版本取快照执行,未传入取库中当前数据(即最新生效版本)。 * * @param request 转换请求。 @@ -401,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; } @@ -431,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 0bc78e5..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,7 +1194,11 @@ 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<>(); + // 同一批次里由后端发号生成的主键集合,用于防御生成器回绕导致的重复主键。 Set newIdSet = new HashSet<>(); String primaryKeyName = onlineTable.getPrimaryKeyColumn().getColumnName(); for (int i = 0; i < masterArray.size(); i++) { @@ -1202,16 +1207,40 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { return this.makeFailedResult("数据验证失败,第 " + (i + 1) + " 张单据的数据格式不正确!"); } JSONObject master = (JSONObject) element; - master.put(primaryKeyName, this.generatePrimaryKey(onlineTable.getPrimaryKeyColumn(), newIdSet)); - Object slaveElement = slaveArray == null ? slaveData : slaveArray.get(i); + // 方案 A:允许每张单据自带分录(masterData[i].slaveData),父子关系由结构保证,不再依赖下标。 + // 这条分录键必须从表头里摘掉,否则会被当成表头字段交给 ORM。 + JSONObject ownSlave = null; + if (master.get("slaveData") instanceof JSONObject) { + ownSlave = master.getJSONObject("slaveData"); + master.remove("slaveData"); + } + // 转换关系同样内嵌在单据上,需摘出后单独处理,同样不能当作表头字段。 + Object billRelation = master.remove("billRelation"); + // 已有主键必须保留:BOTP 转换(formStatus=2)在转换阶段就预发了主键, + // 转换关系表 convert_relation.targetId 记的正是这个 id,覆盖会导致关系对不上。 + if (this.isBlankPrimaryKey(master, primaryKeyName)) { + master.put(primaryKeyName, this.generatePrimaryKey(onlineTable.getPrimaryKeyColumn(), newIdSet)); + } + String masterId = master.get(primaryKeyName).toString(); + if (!usedIdSet.add("H:" + masterId)) { + return this.makeFailedResult("数据验证失败,第 " + (i + 1) + " 张单据的主键 " + masterId + + " 与前面的单据重复,请检查数据!"); + } + // 内嵌分录优先;否则回退到平行数组或整批共享的分录对象(向后兼容)。 + Object slaveElement = ownSlave != null ? ownSlave + : (slaveArray == null ? slaveData : slaveArray.get(i)); JSONObject slave = slaveElement instanceof JSONObject ? (JSONObject) slaveElement : null; if (slave != null) { - this.makeupEntryPrimaryKey(slave, newIdSet); + String entryError = this.makeupEntryPrimaryKey(slave, onlineTable, newIdSet, usedIdSet); + if (entryError != null) { + return this.makeFailedResult("数据验证失败,第 " + (i + 1) + " 张单据的分录" + entryError); + } } masterList.add(master); slaveList.add(slave); + relationList.add(billRelation); } - return onlineOperationService.executeBatchSaveInTransaction(masterList, slaveList, + return onlineOperationService.executeBatchSaveInTransaction(masterList, slaveList, relationList, onlinePluginExecuteDto, onlineForm, sortDtoList); } @@ -1224,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) { @@ -1243,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)); @@ -1301,19 +1340,32 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } /** - * 给缺少主键的分录行补发主键,使每张单据的分录落到各自的表头下。 + * 给缺少主键的分录行补发主键,并校验分录挂在合法的分录表下。 + * + *

key 同时接受 tableId 与表名(见 {@link #resolveEntryTable})。查不到表或该表不是本表单 + * 主表下挂的分录表时直接报错——原先静默跳过会让整段分录被无声丢弃。

* - * @param slaveData 一张单据的分录数据,key 为分录表 tableId。 - * @param newIdSet 本批次已生成的主键集合,用于防重复。 + * @param slaveData 一张单据的分录数据。 + * @param masterTable 表单主表,用于校验分录表归属。 + * @param newIdSet 本批次后端发号生成的主键集合,用于防重复。 + * @param usedIdSet 本批次出现过的全部主键(含调用方预生成的),用于查重。 + * @return 出错信息;无错返回 null。 */ - private void makeupEntryPrimaryKey(JSONObject slaveData, Set newIdSet) { - for (String tableIdStr : slaveData.keySet()) { - OnlineTable entryTable = onlineTableService.getOnlineTableFromCache(this.convertToLongId(tableIdStr)); - if (entryTable == null || entryTable.getPrimaryKeyColumn() == null) { + private String makeupEntryPrimaryKey(JSONObject slaveData, OnlineTable masterTable, + Set newIdSet, Set usedIdSet) { + for (String entryKey : slaveData.keySet()) { + OnlineTable entryTable = this.resolveEntryTable(entryKey); + if (entryTable == null) { + return "的表分键 [" + entryKey + "] 不是有效的表(应为分录表的 tableId 或表名),请检查!"; + } + if (!this.isEntryTableOf(masterTable, entryTable)) { + return "的表分键 [" + entryKey + "] 不是当前表单的分录表,请检查!"; + } + if (entryTable.getPrimaryKeyColumn() == null) { continue; } String primaryKeyName = entryTable.getPrimaryKeyColumn().getColumnName(); - Object entryValue = slaveData.get(tableIdStr); + Object entryValue = slaveData.get(entryKey); // 分录数据既可能是「行数组」,也可能是「单行对象」(一对一关系),两种都补齐主键。 List entryRowList = new LinkedList<>(); if (entryValue instanceof JSONArray) { @@ -1326,11 +1378,77 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { entryRowList.add((JSONObject) entryValue); } for (JSONObject entryRow : entryRowList) { - if (entryRow.get(primaryKeyName) == null) { + // 已有主键必须保留(BOTP 转换预发号的分录 id 已被转换关系表引用),只给缺主键的行补号。 + if (this.isBlankPrimaryKey(entryRow, primaryKeyName)) { entryRow.put(primaryKeyName, this.generatePrimaryKey(entryTable.getPrimaryKeyColumn(), newIdSet)); } + String entryId = entryRow.get(primaryKeyName).toString(); + if (!usedIdSet.add("E:" + entryId)) { + return "主键 " + entryId + " 与其他分录行重复,请检查数据!"; + } } } + return null; + } + + /** + * 解析分录表的 key:数字按 tableId 查,非数字按表名查。 + * + * @param entryKey slaveData 的 key。 + * @return 在线表;解析不到返回 null。 + */ + private OnlineTable resolveEntryTable(String entryKey) { + Long tableId = this.convertToLongId(StrUtil.trim(entryKey)); + if (tableId != null) { + return onlineTableService.getOnlineTableFromCache(tableId); + } + OnlineTable table = onlineTableService.getOne(new LambdaQueryWrapper() + .eq(OnlineTable::getTableName, StrUtil.trim(entryKey)).last("LIMIT 1")); + return table; + } + + /** + * 校验一张表是否为本表单主表下挂的分录表(ORM 元数据里 relationType=1 的链接列)。 + * + * @param masterTable 表单主表。 + * @param entryTable 待校验的表。 + * @return 是分录表返回 true。 + */ + private boolean isEntryTableOf(OnlineTable masterTable, OnlineTable entryTable) { + if (masterTable == null || entryTable == null + || masterTable.getTableId().equals(entryTable.getTableId())) { + return false; + } + EntityTable entityTable = ormGenDataSourceUtil.getEntityTable(masterTable.getTableName()); + if (entityTable == null || entityTable.getEntityColumnList() == null) { + return false; + } + for (EntityColumn entityColumn : entityTable.getEntityColumnList()) { + if (!(entityColumn instanceof LinkEntityColumn)) { + continue; + } + EntityLinkRelation relation = ((LinkEntityColumn) entityColumn).getEntityLinkRelation(); + if (relation == null || relation.getRelationType() == null || relation.getRelationType() != 1) { + continue; + } + if (relation.getSuperTable() != null + && entryTable.getTableName().equals(relation.getSuperTable().getTableName())) { + return true; + } + } + return false; + } + + /** + * 判断主键是否缺失:null、空串、"null" 都视为缺失。 + * + * @param data 行数据。 + * @param primaryKeyName 主键字段名。 + * @return 需要后端补号时返回 true。 + */ + private boolean isBlankPrimaryKey(JSONObject data, String primaryKeyName) { + Object id = data.get(primaryKeyName); + return id == null || StrUtil.isBlank(id.toString()) || "null".equalsIgnoreCase(id.toString()); } /** @@ -1434,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); @@ -1441,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;