Browse Source

fix(botp): 打通「转换 → 手动保存 → 反写」链路并修复反写失效

- 移除转换时的反写触发:删除 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 判断列是否存在,可重复执行
dev-lml
lml 2 days ago
parent
commit
a1c7bc8dc8
  1. 9
      common/common-botp/src/main/java/apelet/common/botp/dto/ConvertedBill.java
  2. 67
      common/common-botp/src/main/java/apelet/common/botp/service/execute/BotpService.java
  3. 48
      common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java
  4. 121
      common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertRelationFacade.java
  5. 119
      common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java
  6. 8
      common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java
  7. 27
      common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java
  8. 34
      common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java
  9. 2
      common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java
  10. 109
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java
  11. 40
      zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql

9
common/common-botp/src/main/java/apelet/common/botp/dto/ConvertedBill.java

@ -1,6 +1,7 @@
package apelet.common.botp.dto; package apelet.common.botp.dto;
import apelet.common.botp.service.execute.entity.BillData; import apelet.common.botp.service.execute.entity.BillData;
import apelet.common.botp.service.execute.entity.BillRelation;
import lombok.Data; import lombok.Data;
/** /**
@ -28,4 +29,12 @@ public class ConvertedBill {
* 前端打开目标单要用的数据(表头 + 分录)。 * 前端打开目标单要用的数据(表头 + 分录)。
*/ */
private BillData billData; private BillData billData;
/**
* 本单据的转换关联关系(单据级 + 分录级)。
*
* <p>autoSave 关闭时 {@code convert_relation} 不在转换阶段写入,需由前端随保存报文带回、
* 由保存接口补写,否则后续反写 / 上查下查找不到来源。</p>
*/
private BillRelation billRelation;
} }

67
common/common-botp/src/main/java/apelet/common/botp/service/execute/BotpService.java

@ -111,7 +111,7 @@ public class BotpService {
executePlugins(convertPluginEntryList, context, true); executePlugins(convertPluginEntryList, context, true);
if (isAutoSaveOn(convertRule)) { if (isAutoSaveOn(convertRule)) {
addNew(result, destBillData.getTableName()); addNew(result, destBillData.getTableName());
saveRelation(result.getBillRelationList().get(0), destBillData); saveRelation(result.getBillRelationList().get(0), destBillData, result.getEntryIdMap());
} }
executePlugins(convertPluginEntryList, context, false); executePlugins(convertPluginEntryList, context, false);
return result; return result;
@ -575,16 +575,69 @@ public class BotpService {
/** /**
* 落库目标单据并写转换关系。 * 落库目标单据并写转换关系。
* *
* <p><b>为什么落库后要校正分录主键</b>:{@code initDestBillData} 建骨架时给每条分录预发了主键,
* 关系分录 {@code targe_entry_id} 记的就是这个预发值;但 ORM 的 {@code addNew} 在真正插入分录时
* 会重新发号(并把真实主键回写到同一个 ObjectValue 实例上)。不校正的话关系里存的是库里不存在的 id,
* 反写按它查目标分录必然为空,于是静默跳过并打「反写跳过:关联的分录行已不存在」。</p>
*
* @param result 转换结果。 * @param result 转换结果。
* @param tableName 目标单表名。 * @param tableName 目标单表名。
*/ */
private void addNew(BtpTransformResult result, String tableName) { private void addNew(BtpTransformResult result, String tableName) {
ObjectValue destBillData = result.getBillDataList().getObject(0); ObjectValue destBillData = result.getBillDataList().getObject(0);
// 落库前记下「预发主键 → 分录对象实例」,落库后从实例上读真实主键做映射。
Map<String, ObjectValue> plannedEntryMap = this.collectPlannedEntries(destBillData);
try { try {
ormGenDataSourceUtil.addNew(tableName, destBillData); ormGenDataSourceUtil.addNew(tableName, destBillData);
} catch (Exception e) { } catch (Exception e) {
throw new BotpMappingException("目标单据落库失败,表名=" + tableName, e); throw new BotpMappingException("目标单据落库失败,表名=" + tableName, e);
} }
result.setEntryIdMap(this.buildRealEntryIdMap(plannedEntryMap));
}
/**
* 收集落库前各分录行「预发主键 → 对象实例」。
*
* @param destBillData 目标单数据(落库前)。
* @return 预发主键 → 分录对象。
*/
private Map<String, ObjectValue> collectPlannedEntries(ObjectValue destBillData) {
Map<String, ObjectValue> 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<String, String> buildRealEntryIdMap(Map<String, ObjectValue> plannedMap) {
Map<String, String> realIdMap = new HashMap<>(plannedMap.size());
for (Map.Entry<String, ObjectValue> 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 billRelation 关联关系。
* @param destBillData 落库后的目标单数据。 * @param destBillData 落库后的目标单数据。
* @param entryIdMap 分录「预发主键 → 真实主键」映射,用于把 targe_entry_id 校正为落库后的 id。
*/ */
private void saveRelation(BillRelation billRelation, ObjectValue destBillData) { private void saveRelation(BillRelation billRelation, ObjectValue destBillData, Map<String, String> entryIdMap) {
ConvertRelation convertRelation = new ConvertRelation(); ConvertRelation convertRelation = new ConvertRelation();
convertRelation.setId(billRelation.getId()); convertRelation.setId(billRelation.getId());
convertRelation.setConvertRuleId(billRelation.getConvertRuleId()); convertRelation.setConvertRuleId(billRelation.getConvertRuleId());
@ -617,7 +671,14 @@ public class BotpService {
entry.setSourceEntryNumber(billRelationEntry.getSourceEntryNumber()); entry.setSourceEntryNumber(billRelationEntry.getSourceEntryNumber());
entry.setSourceId(billRelationEntry.getSourceId()); entry.setSourceId(billRelationEntry.getSourceId());
entry.setSourceNumber(billRelationEntry.getSourceNumber()); 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.setTargetId(String.valueOf(destBillData.getPkValue()));
entry.setTargeEntryNumber(billRelationEntry.getTargeEntryNumber()); entry.setTargeEntryNumber(billRelationEntry.getTargeEntryNumber());
entry.setTargetNumber(billRelationEntry.getTargetNumber()); entry.setTargetNumber(billRelationEntry.getTargetNumber());

48
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.PluginTypeEnum;
import apelet.common.botp.model.constant.SplitMergeStrategyEnum; import apelet.common.botp.model.constant.SplitMergeStrategyEnum;
import apelet.common.botp.service.IConvertPluginEntryService; 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.BtpTransformResult;
import apelet.common.botp.service.execute.entity.ConvertBatch; import apelet.common.botp.service.execute.entity.ConvertBatch;
import apelet.common.botp.service.execute.entity.ResolvedConvertRule; import apelet.common.botp.service.execute.entity.ResolvedConvertRule;
import apelet.common.botp.util.BillDataConverter; import apelet.common.botp.util.BillDataConverter;
import apelet.common.botp.util.Constant; import apelet.common.botp.util.Constant;
import apelet.common.core.botp.BotpWriteBackRequest;
import apelet.common.core.object.ObjectCollection; import apelet.common.core.object.ObjectCollection;
import apelet.common.core.object.ObjectValue; import apelet.common.core.object.ObjectValue;
import apelet.common.generator.dao.OnlFormHeadMapper; import apelet.common.generator.dao.OnlFormHeadMapper;
@ -94,8 +94,6 @@ public class ConvertOrchestrator {
@Autowired @Autowired
private BotpService botpService; private BotpService botpService;
@Autowired @Autowired
private WriteBackOrchestrator writeBackOrchestrator;
@Autowired
private BillDataConverter billDataConverter; private BillDataConverter billDataConverter;
@Autowired @Autowired
private RuleVersionResolver ruleVersionResolver; 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) { if (!autoSaveOn) {
result.setBatchSavePayload(this.buildBatchSavePayload(result)); result.setBatchSavePayload(this.buildBatchSavePayload(result));
} }
@ -203,6 +208,11 @@ public class ConvertOrchestrator {
master.putAll(bill.getBillData().getHeadData()); master.putAll(bill.getBillData().getHeadData());
} }
master.put("slaveData", this.buildSlaveData(bill, result.getEntryTables())); master.put("slaveData", this.buildSlaveData(bill, result.getEntryTables()));
// 转换关系统一内嵌在单据上:autoSave 关闭时它没落库,保存接口据此补写,
// 放在单据里而不是并列数组,父子关系同样由结构而非下标保证。
if (bill.getBillRelation() != null) {
master.put("billRelation", bill.getBillRelation());
}
masterDataList.add(master); masterDataList.add(master);
} }
Map<String, Object> dto = new LinkedHashMap<>(); Map<String, Object> dto = new LinkedHashMap<>();
@ -537,6 +547,10 @@ public class ConvertOrchestrator {
Object number = target.get(billNumberColumn); Object number = target.get(billNumberColumn);
convertedBill.setBillNumber(number == null ? null : String.valueOf(number)); convertedBill.setBillNumber(number == null ? null : String.valueOf(number));
} }
// 关联关系随结果带出:autoSave 关闭时它不会在转换阶段落库,要由前端保存时带回再补写。
if (!rawResult.getBillRelationList().isEmpty()) {
convertedBill.setBillRelation(rawResult.getBillRelationList().get(0));
}
return convertedBill; return convertedBill;
} }
@ -567,28 +581,6 @@ public class ConvertOrchestrator {
} }
/** /**
* autoSave 落库后触发「保存即反写」。
*
* <p><b>为什么必须在这里补一刀</b>:autoSave 走 {@code ormGenDataSourceUtil.addNew} 直接落库,
* 绕过了在线表单的保存操作链,保存插件不会被触发,反写就丢了。这也是原先把
* ReverseWritingPlugIn 配成「转换插件」挂在转换流程里的原因。</p>
*
* <p>与反写编排器共事务:反写失败会连同本次转换一起回滚(FR-4.4 / FR-6.7)。</p>
*
* @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"} 均视为开,空值与其它值视为关。 * autoSave 判定口径统一:{@code "1"} 与 {@code "true"} 均视为开,空值与其它值视为关。
* *
* <p>原实现两处不一致——BotpService 判 {@code "true"}、ConvertRuleServiceImpl 判 {@code "1"}, * <p>原实现两处不一致——BotpService 判 {@code "true"}、ConvertRuleServiceImpl 判 {@code "1"},

121
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;
/**
* 转换关系补写实现。
*
* <p><b>背景</b>:{@code convert_relation} / {@code convert_relation_entry} 原来只在
* {@code BotpService#transform} 的 autoSave 分支写入。{@code formStatus=2}(未落库、前端手动保存)
* 时不写,保存后反写就找不到来源单。本实现负责在「保存落库之后」把关系补上。</p>
*
* <p><b>幂等</b>:按关联关系主键判重,重复保存不会写出重复关系。</p>
*
*/
@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<BillRelationEntry> 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);
}
}
}

119
common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java

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

8
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 lombok.Data;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* @author Soon * @author Soon
@ -19,5 +21,11 @@ public class BtpTransformResult {
private List<BillRelation> billRelationList = new ArrayList<>(); private List<BillRelation> billRelationList = new ArrayList<>();
/**
* 分录「预发主键 → 落库后的真实主键」映射。
*
* <p>ORM 插入分录时会重新发号,关系表必须存真实主键,否则反写按 targe_entry_id 查不到行。</p>
*/
private Map<String, String> entryIdMap = new HashMap<>();
} }

27
common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java

@ -0,0 +1,27 @@
package apelet.common.core.botp;
/**
* 转换关系落库门面(依赖反转用)。
*
* <p><b>为什么需要</b>:转换关系表 {@code convert_relation} / {@code convert_relation_entry} 只在
* BOTP 转换的 autoSave 分支写入。当 {@code formStatus=2}(未落库、由前端手动保存)时,
* 转换阶段不会写关系,保存接口落库后必须补写,否则后续「保存即反写」「上查下查」都找不到来源单。</p>
*
* <p><b>为什么放在 common-core</b>:与 {@link BotpReverseWriteFacade} 同理——补写实现与实体都在
* common-botp,而补写时机在 common-online,直接依赖会成环。</p>
*
* <p>调用方应通过 {@link BotpReverseWriteSupport#saveRelationQuietly} 调用,未挂载 botp 时静默跳过。</p>
*
*/
public interface BotpConvertRelationFacade {
/**
* 按转换结果下发的关联关系补写转换关系表。
*
* <p>已存在的关联关系(按关联关系主键判重)直接跳过,保证重复保存不会产生重复关系。</p>
*
* @param billRelationJson 单张目标单的关联关系 JSON 串,结构见 BillRelation。
* @param targetBillId 目标单落库后的真实主键,会覆盖 JSON 里的 targetId。
*/
void saveBillRelation(String billRelationJson, Long targetBillId);
}

34
common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java

@ -53,6 +53,27 @@ public final class BotpReverseWriteSupport {
} }
/** /**
* 补写转换关系;当前应用未挂载 botp 时静默跳过。
*
* <p>用于 {@code formStatus=2}(未落库、前端手动保存)的场景:转换阶段没写 convert_relation,
* 保存落库后由这里补上,否则后续反写找不到来源单。</p>
*
* @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(不抛异常)。 * 从 Spring 容器取反写门面实现,取不到返回 null(不抛异常)。
* *
* <p>ApplicationContextHolder 没有安全取法,这里同时兜住两类异常:容器里没有该 bean * <p>ApplicationContextHolder 没有安全取法,这里同时兜住两类异常:容器里没有该 bean
@ -61,8 +82,19 @@ public final class BotpReverseWriteSupport {
* @return 门面实现;未挂载时为 null。 * @return 门面实现;未挂载时为 null。
*/ */
private static BotpReverseWriteFacade getFacadeQuietly() { private static BotpReverseWriteFacade getFacadeQuietly() {
return getFacadeQuietly(BotpReverseWriteFacade.class);
}
/**
* 按类型安全取门面实现。
*
* @param facadeType 门面接口类型。
* @param <T> 门面类型。
* @return 实现;未挂载时为 null。
*/
private static <T> T getFacadeQuietly(Class<T> facadeType) {
try { try {
return ApplicationContextHolder.getBean(BotpReverseWriteFacade.class); return ApplicationContextHolder.getBean(facadeType);
} catch (BeansException | MyRuntimeException e) { } catch (BeansException | MyRuntimeException e) {
return null; return null;
} }

2
common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java

@ -340,12 +340,14 @@ public interface OnlineOperationService {
* *
* @param masterList 表头数据列表,主键已补齐。 * @param masterList 表头数据列表,主键已补齐。
* @param slaveList 分录数据列表,下标与表头对齐。 * @param slaveList 分录数据列表,下标与表头对齐。
* @param relationList 转换关系列表,下标与表头对齐;元素可为 JSONObject 或 null。
* @param onlinePluginExecuteDto 操作按钮等参数。 * @param onlinePluginExecuteDto 操作按钮等参数。
* @param onlineForm 当前表单。 * @param onlineForm 当前表单。
* @param sortDtoList 已解析并按执行顺序排好的插件列表。 * @param sortDtoList 已解析并按执行顺序排好的插件列表。
* @return 操作结果。 * @return 操作结果。
*/ */
OperationResult executeBatchSaveInTransaction(List<JSONObject> masterList, List<JSONObject> slaveList, OperationResult executeBatchSaveInTransaction(List<JSONObject> masterList, List<JSONObject> slaveList,
List<Object> relationList,
OnlinePluginExecuteDto onlinePluginExecuteDto, OnlinePluginExecuteDto onlinePluginExecuteDto,
OnlineForm onlineForm, OnlineForm onlineForm,
List<OnlinePluginExecuteSortDto> sortDtoList); List<OnlinePluginExecuteSortDto> sortDtoList);

109
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.MultiDatabaseWriteMethod;
import apelet.common.core.annotation.MyDataSourceResolver; import apelet.common.core.annotation.MyDataSourceResolver;
import apelet.common.core.botp.BotpReverseWriteSupport;
import apelet.common.core.cache.CacheConfig; import apelet.common.core.cache.CacheConfig;
import apelet.common.core.constant.*; import apelet.common.core.constant.*;
import apelet.common.core.exception.MyRuntimeException; import apelet.common.core.exception.MyRuntimeException;
@ -1193,6 +1194,8 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
} }
List<JSONObject> masterList = new LinkedList<>(); List<JSONObject> masterList = new LinkedList<>();
List<JSONObject> slaveList = new LinkedList<>(); List<JSONObject> slaveList = new LinkedList<>();
// 与 masterList 下标对齐:第 i 张单据的转换关系 JSON(无则 null)。
List<Object> relationList = new LinkedList<>();
// 防重复用:已经出现在本批数据里的主键(含调用方预生成的,如 BOTP 转换预发号的 id)。 // 防重复用:已经出现在本批数据里的主键(含调用方预生成的,如 BOTP 转换预发号的 id)。
Set<String> usedIdSet = new HashSet<>(); Set<String> usedIdSet = new HashSet<>();
// 同一批次里由后端发号生成的主键集合,用于防御生成器回绕导致的重复主键。 // 同一批次里由后端发号生成的主键集合,用于防御生成器回绕导致的重复主键。
@ -1211,6 +1214,8 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
ownSlave = master.getJSONObject("slaveData"); ownSlave = master.getJSONObject("slaveData");
master.remove("slaveData"); master.remove("slaveData");
} }
// 转换关系同样内嵌在单据上,需摘出后单独处理,同样不能当作表头字段。
Object billRelation = master.remove("billRelation");
// 已有主键必须保留:BOTP 转换(formStatus=2)在转换阶段就预发了主键, // 已有主键必须保留:BOTP 转换(formStatus=2)在转换阶段就预发了主键,
// 转换关系表 convert_relation.targetId 记的正是这个 id,覆盖会导致关系对不上。 // 转换关系表 convert_relation.targetId 记的正是这个 id,覆盖会导致关系对不上。
if (this.isBlankPrimaryKey(master, primaryKeyName)) { if (this.isBlankPrimaryKey(master, primaryKeyName)) {
@ -1233,8 +1238,9 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
} }
masterList.add(master); masterList.add(master);
slaveList.add(slave); slaveList.add(slave);
relationList.add(billRelation);
} }
return onlineOperationService.executeBatchSaveInTransaction(masterList, slaveList, return onlineOperationService.executeBatchSaveInTransaction(masterList, slaveList, relationList,
onlinePluginExecuteDto, onlineForm, sortDtoList); onlinePluginExecuteDto, onlineForm, sortDtoList);
} }
@ -1247,13 +1253,16 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
* *
* @param masterList 表头数据列表。 * @param masterList 表头数据列表。
* @param slaveList 分录数据列表,下标与表头对齐。 * @param slaveList 分录数据列表,下标与表头对齐。
* @param relationList 转换关系列表,下标与表头对齐;元素可为 JSONObject 或 null。
* @param onlinePluginExecuteDto 操作按钮等参数。 * @param onlinePluginExecuteDto 操作按钮等参数。
* @param onlineForm 当前表单。 * @param onlineForm 当前表单。
* @param sortDtoList 已解析并按执行顺序排好的插件列表。
* @return 操作结果。 * @return 操作结果。
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@Override @Override
public OperationResult executeBatchSaveInTransaction(List<JSONObject> masterList, List<JSONObject> slaveList, public OperationResult executeBatchSaveInTransaction(List<JSONObject> masterList, List<JSONObject> slaveList,
List<Object> relationList,
OnlinePluginExecuteDto onlinePluginExecuteDto, OnlinePluginExecuteDto onlinePluginExecuteDto,
OnlineForm onlineForm, OnlineForm onlineForm,
List<OnlinePluginExecuteSortDto> sortDtoList) { List<OnlinePluginExecuteSortDto> sortDtoList) {
@ -1266,7 +1275,14 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
if (singleResult != null && !singleResult.isSuccess()) { if (singleResult != null && !singleResult.isSuccess()) {
throw new OnlineRuntimeException(singleResult.getMessage()); 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 operationResult = new OperationResult();
operationResult.setInfo(String.join(",", savedIdList)); operationResult.setInfo(String.join(",", savedIdList));
@ -1536,6 +1552,10 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
removeFileBusinessIdMap = this.collectFileBusinessIdForRemove(onlineTable, onlinePluginExecuteDto.getBillId()); removeFileBusinessIdMap = this.collectFileBusinessIdForRemove(onlineTable, onlinePluginExecuteDto.getBillId());
} }
OperationResult operationResult = exePlugin(list, onlinePluginExecuteDto.getBillId(), onlineTable.getTableName(), finalObjectValue, onlineForm); OperationResult operationResult = exePlugin(list, onlinePluginExecuteDto.getBillId(), onlineTable.getTableName(), finalObjectValue, onlineForm);
// ORM 落库会给新增记录重新发号(即使入参已带 id),真实主键此时才能确定。
// 批量保存要靠它补写转换关系,不回写的话关系表会指向不存在的记录,
// 之后用户改单保存触发反写就会「找不到关联关系」而静默跳过。
this.syncSavedIds(finalObjectValue, masterData, slaveData);
// ORM保存会把生成的主键回写到入参的ObjectValue实例上,此时才能确定附件归属的业务主键。 // ORM保存会把生成的主键回写到入参的ObjectValue实例上,此时才能确定附件归属的业务主键。
this.flushPendingFileColumn(pendingFileList); this.flushPendingFileColumn(pendingFileList);
this.removeFileByBusinessIdMap(removeFileBusinessIdMap); this.removeFileByBusinessIdMap(removeFileBusinessIdMap);
@ -1543,6 +1563,91 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
} }
/** /**
* 把 ORM 落库后回写的真实主键同步回入参 JSON。
*
* <p><b>为什么需要</b>:{@code ormGenDataSourceUtil.addNew} 对新增记录会重新发号,入参里的
* 「预生成 id」(BOTP 转换阶段发的号)因此与库里不一致。调用方若拿预生成 id 去写
* {@code convert_relation.target_id / targe_entry_id},关系就指向不存在的记录,
* 之后按真实主键查关系必然为空——表现为「改单保存不反写」。</p>
*
* <p>分录行按「key 顺序 + 行下标」对应:{@code OnlineFormServiceImpl#getObjectValue} 就是按这个
* 顺序把行放进 ObjectValue 的分录集合的,两边顺序一致。</p>
*
* @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<JSONObject> 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<OnlineTable>()
.eq(OnlineTable::getTableName, StrUtil.trim(entryKey)).last("LIMIT 1"));
}
/**
* 把分录值统一成 JSONObject 行列表。
*
* @param entryValue 分录值,可为数组或单行对象。
* @return 行列表;格式不符时返回空列表。
*/
private List<JSONObject> toJsonRowList(Object entryValue) {
List<JSONObject> 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 为 {列名: 值} 的等值条件;为空表示无筛选(即整表)。 * queryParams 为 {列名: 值} 的等值条件;为空表示无筛选(即整表)。
*/ */

40
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;
Loading…
Cancel
Save