Browse Source

Merge branch 'dev-lml' of http://124.222.6.97:3000/sunquan/apelet_tenant into feature/tenant-sync-online

feature/tenant-sync-online
chenchuchuan 2 days ago
parent
commit
2684583fae
  1. 10
      common/common-botp/src/main/java/apelet/common/botp/dto/ConvertResult.java
  2. 9
      common/common-botp/src/main/java/apelet/common/botp/dto/ConvertedBill.java
  3. 67
      common/common-botp/src/main/java/apelet/common/botp/service/execute/BotpService.java
  4. 182
      common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java
  5. 121
      common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertRelationFacade.java
  6. 119
      common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java
  7. 8
      common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java
  8. 27
      common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java
  9. 34
      common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java
  10. 2
      common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java
  11. 237
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java
  12. 40
      zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql

10
common/common-botp/src/main/java/apelet/common/botp/dto/ConvertResult.java

@ -4,6 +4,7 @@ import lombok.Data; @@ -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 { @@ -73,4 +74,13 @@ public class ConvertResult {
* executePluginBatch 入参 slaveData(key 为 tableId)。
*/
private List<ConvertEntryTable> entryTables = new ArrayList<>();
/**
* 可直接提交给 /onlineOperation/executePluginBatch 的完整报文。
*
* <p>结构:{@code { onlinePluginExecuteDto: {formId, buttonName}, masterData: [{表头..., slaveData: {...}}] }}。
* 只有 formStatus == "2"(未落库)且能确定目标编辑表单与其保存按钮时才产出,否则为 null,
* 前端此时按 {@link #entryTables} 自行组装。</p>
*/
private Map<String, Object> batchSavePayload;
}

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

@ -1,6 +1,7 @@ @@ -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 { @@ -28,4 +29,12 @@ public class ConvertedBill {
* 前端打开目标单要用的数据(表头 + 分录)。
*/
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 { @@ -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 { @@ -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 tableName 目标单表名。
*/
private void addNew(BtpTransformResult result, String tableName) {
ObjectValue destBillData = result.getBillDataList().getObject(0);
// 落库前记下「预发主键 → 分录对象实例」,落库后从实例上读真实主键做映射。
Map<String, ObjectValue> 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<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 { @@ -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<String, String> entryIdMap) {
ConvertRelation convertRelation = new ConvertRelation();
convertRelation.setId(billRelation.getId());
convertRelation.setConvertRuleId(billRelation.getConvertRuleId());
@ -617,7 +671,14 @@ public class BotpService { @@ -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());

182
common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java

@ -14,12 +14,12 @@ import apelet.common.botp.model.constant.BotpRuleConstant; @@ -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 { @@ -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 { @@ -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 的完整入参。
*
* <p>每张单据的分录内嵌在自身 masterData 元素里({@code slaveData} 键),父子关系由结构而非下标保证;
* 分录 key 用 tableId(后端同时兼容表名)。</p>
*
* <p>任何一环无法确定(非在线表单、缺编辑表单 id、找不到绑定保存插件的按钮)都返回 null,
* 由前端按 entryTables 自行组装,不做猜测。</p>
*
* @param result 转换结果(需已填充 bills / entryTables / editFormId)。
* @return 可直接提交的报文;无法组装时返回 null。
*/
private Map<String, Object> 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<Map<String, Object>> masterDataList = new ArrayList<>();
for (ConvertedBill bill : result.getBills()) {
if (bill.getBillData() == null) {
return null;
}
Map<String, Object> 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<String, Object> dto = new LinkedHashMap<>();
dto.put("formId", result.getEditFormId());
dto.put("buttonName", saveButtonName);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("onlinePluginExecuteDto", dto);
payload.put("masterData", masterDataList);
return payload;
}
/**
* 把一张单据的分录按 tableId 汇总成 slaveData 结构。
*
* <p>同一张表可能有多个关系(dataKey)指向它,这里做合并而不是覆盖,避免静默丢行。</p>
*
* @param bill 目标单据。
* @param entryTables 分录表信息。
* @return slaveData:{tableId: [行...]}。
*/
private Map<String, Object> buildSlaveData(ConvertedBill bill, List<ConvertEntryTable> entryTables) {
Map<String, Object> slaveData = new LinkedHashMap<>();
Map<String, List<Map<String, Object>>> entryData = bill.getBillData().getEntryData();
if (entryData == null || entryTables == null) {
return slaveData;
}
for (ConvertEntryTable entryTable : entryTables) {
// dataKey 是 BillData.entryData 里的 key(关系名),不对时按表名兜底再取一次。
List<Map<String, Object>> 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<Map<String, Object>> merged = new ArrayList<>();
Object existing = slaveData.get(tableIdKey);
if (existing instanceof List) {
merged.addAll((List<Map<String, Object>>) existing);
}
merged.addAll(rows);
slaveData.put(tableIdKey, merged);
}
return slaveData;
}
/**
* 从目标编辑表单的 widgetJson 中找出绑定保存插件的按钮名。
*
* <p>按插件类名精确匹配(与 executePluginBatch 的校验口径一致),不按按钮中文名猜。</p>
*
* @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 { @@ -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 { @@ -431,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"} 均视为开,空值与其它值视为关。
*
* <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 @@ @@ -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 { @@ -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>逐对处理关联关系:目标分录行的值作为「本次值」,源分录行的值作为「原值」,
@ -459,43 +505,62 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade { @@ -459,43 +505,62 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
*/
private void applyFormula(ReverseWritingFormula formula, List<ConvertRelationEntry> entries, WriteBackContext context, List<WriteBackPlugIn> 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 { @@ -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<ConvertRelationEntry>()
.eq(ConvertRelationEntry::getId, entry.getId())
.set(ConvertRelationEntry::getBeforeValue, beforeValue));
entry.setBeforeValue(beforeValue);
}
@ -708,6 +775,22 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade { @@ -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 表名。

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; @@ -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 { @@ -19,5 +21,11 @@ public class BtpTransformResult {
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 @@ @@ -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 { @@ -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(不抛异常)。
*
* <p>ApplicationContextHolder 没有安全取法,这里同时兜住两类异常:容器里没有该 bean
@ -61,8 +82,19 @@ public final class BotpReverseWriteSupport { @@ -61,8 +82,19 @@ public final class BotpReverseWriteSupport {
* @return 门面实现;未挂载时为 null。
*/
private static BotpReverseWriteFacade getFacadeQuietly() {
return getFacadeQuietly(BotpReverseWriteFacade.class);
}
/**
* 按类型安全取门面实现。
*
* @param facadeType 门面接口类型。
* @param <T> 门面类型。
* @return 实现;未挂载时为 null。
*/
private static <T> T getFacadeQuietly(Class<T> facadeType) {
try {
return ApplicationContextHolder.getBean(BotpReverseWriteFacade.class);
return ApplicationContextHolder.getBean(facadeType);
} catch (BeansException | MyRuntimeException e) {
return null;
}

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

@ -340,12 +340,14 @@ public interface OnlineOperationService { @@ -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<JSONObject> masterList, List<JSONObject> slaveList,
List<Object> relationList,
OnlinePluginExecuteDto onlinePluginExecuteDto,
OnlineForm onlineForm,
List<OnlinePluginExecuteSortDto> sortDtoList);

237
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java

@ -2,6 +2,7 @@ package apelet.common.online.service.impl; @@ -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 { @@ -1193,7 +1194,11 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
}
List<JSONObject> masterList = new LinkedList<>();
List<JSONObject> slaveList = new LinkedList<>();
// 同一批次里发号生成的主键集合,用于防御生成器回绕或配置问题导致的重复主键。
// 与 masterList 下标对齐:第 i 张单据的转换关系 JSON(无则 null)。
List<Object> relationList = new LinkedList<>();
// 防重复用:已经出现在本批数据里的主键(含调用方预生成的,如 BOTP 转换预发号的 id)。
Set<String> usedIdSet = new HashSet<>();
// 同一批次里由后端发号生成的主键集合,用于防御生成器回绕导致的重复主键。
Set<Long> 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 { @@ -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 { @@ -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<JSONObject> masterList, List<JSONObject> slaveList,
List<Object> relationList,
OnlinePluginExecuteDto onlinePluginExecuteDto,
OnlineForm onlineForm,
List<OnlinePluginExecuteSortDto> sortDtoList) {
@ -1243,7 +1275,14 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { @@ -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 { @@ -1301,19 +1340,32 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
}
/**
* 给缺少主键的分录行补发主键,使每张单据的分录落到各自的表头下。
* 给缺少主键的分录行补发主键,并校验分录挂在合法的分录表下。
*
* <p>key 同时接受 tableId 与表名(见 {@link #resolveEntryTable})。查不到表或该表不是本表单
* 主表下挂的分录表时直接报错——原先静默跳过会让整段分录被无声丢弃。</p>
*
* @param slaveData 一张单据的分录数据,key 为分录表 tableId。
* @param newIdSet 本批次已生成的主键集合,用于防重复。
* @param slaveData 一张单据的分录数据。
* @param masterTable 表单主表,用于校验分录表归属。
* @param newIdSet 本批次后端发号生成的主键集合,用于防重复。
* @param usedIdSet 本批次出现过的全部主键(含调用方预生成的),用于查重。
* @return 出错信息;无错返回 null。
*/
private void makeupEntryPrimaryKey(JSONObject slaveData, Set<Long> 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<Long> newIdSet, Set<String> 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<JSONObject> entryRowList = new LinkedList<>();
if (entryValue instanceof JSONArray) {
@ -1326,11 +1378,77 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { @@ -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<OnlineTable>()
.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 { @@ -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 { @@ -1441,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 为 {列名: 值} 的等值条件;为空表示无筛选(即整表)。
*/

40
zz-resource/db-scripts/2026-10-27-convert-relation-entry-writeback-script.sql

@ -0,0 +1,40 @@ @@ -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