Browse Source

fix(botp): 未落库转换结果直接产出批量保存报文,并在批量保存中保留预生成主键

- ConvertResult 新增 batchSavePayload:formStatus=2 时后端组装好 executePluginBatch
  的完整入参,前端透传即可;组装不出时返回 null 由前端按 entryTables 降级
- ConvertOrchestrator 新增 buildBatchSavePayload / buildSlaveData / resolveSaveButtonName:
  每张单据表头内嵌自身分录(masterData[i].slaveData),父子关系由结构保证;
  分录按 tableId 汇总、多关系合并而非覆盖;保存按钮按 SaveOperationServicePlugIn 类名精确匹配
- executeBatchSave 支持内嵌分录(摘出后从表头移除,内嵌优先,回退平行数组保持兼容)
- 主键改为有值就保留、缺失才发号:BOTP 预发主键已被 convert_relation.targetId 引用,
  原先无条件覆盖会导致转换关系对不上;并新增表头/分录同批查重
- slaveData 的 key 兼容 tableId 与表名,修复传表名时 Long.valueOf 抛异常
- 新增分录表归属校验,查不到表或非本表单分录表时明确报错,避免分录被静默丢弃
dev-lml
lml 3 days ago
parent
commit
cb9ec85716
  1. 10
      common/common-botp/src/main/java/apelet/common/botp/dto/ConvertResult.java
  2. 136
      common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java
  3. 126
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java

10
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.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 转换结果。 * 转换结果。
@ -73,4 +74,13 @@ public class ConvertResult {
* executePluginBatch 入参 slaveData(key 为 tableId)。 * executePluginBatch 入参 slaveData(key 为 tableId)。
*/ */
private List<ConvertEntryTable> entryTables = new ArrayList<>(); 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;
} }

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

@ -159,10 +159,146 @@ public class ConvertOrchestrator {
if (autoSaveOn) { if (autoSaveOn) {
triggerSaveWriteBack(result, destFormHead); triggerSaveWriteBack(result, destFormHead);
} }
if (!autoSaveOn) {
result.setBatchSavePayload(this.buildBatchSavePayload(result));
}
return 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()));
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 转换请求。 * @param request 转换请求。

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

@ -1193,7 +1193,9 @@ 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<>();
// 同一批次里发号生成的主键集合,用于防御生成器回绕或配置问题导致的重复主键。 // 防重复用:已经出现在本批数据里的主键(含调用方预生成的,如 BOTP 转换预发号的 id)。
Set<String> usedIdSet = new HashSet<>();
// 同一批次里由后端发号生成的主键集合,用于防御生成器回绕导致的重复主键。
Set<Long> newIdSet = new HashSet<>(); Set<Long> newIdSet = new HashSet<>();
String primaryKeyName = onlineTable.getPrimaryKeyColumn().getColumnName(); String primaryKeyName = onlineTable.getPrimaryKeyColumn().getColumnName();
for (int i = 0; i < masterArray.size(); i++) { for (int i = 0; i < masterArray.size(); i++) {
@ -1202,11 +1204,32 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
return this.makeFailedResult("数据验证失败,第 " + (i + 1) + " 张单据的数据格式不正确!"); return this.makeFailedResult("数据验证失败,第 " + (i + 1) + " 张单据的数据格式不正确!");
} }
JSONObject master = (JSONObject) element; JSONObject master = (JSONObject) element;
// 方案 A:允许每张单据自带分录(masterData[i].slaveData),父子关系由结构保证,不再依赖下标。
// 这条分录键必须从表头里摘掉,否则会被当成表头字段交给 ORM。
JSONObject ownSlave = null;
if (master.get("slaveData") instanceof JSONObject) {
ownSlave = master.getJSONObject("slaveData");
master.remove("slaveData");
}
// 已有主键必须保留:BOTP 转换(formStatus=2)在转换阶段就预发了主键,
// 转换关系表 convert_relation.targetId 记的正是这个 id,覆盖会导致关系对不上。
if (this.isBlankPrimaryKey(master, primaryKeyName)) {
master.put(primaryKeyName, this.generatePrimaryKey(onlineTable.getPrimaryKeyColumn(), newIdSet)); master.put(primaryKeyName, this.generatePrimaryKey(onlineTable.getPrimaryKeyColumn(), newIdSet));
Object slaveElement = slaveArray == null ? slaveData : slaveArray.get(i); }
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; JSONObject slave = slaveElement instanceof JSONObject ? (JSONObject) slaveElement : null;
if (slave != 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); masterList.add(master);
slaveList.add(slave); slaveList.add(slave);
@ -1301,19 +1324,32 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
} }
/** /**
* 给缺少主键的分录行补发主键,使每张单据的分录落到各自的表头下。 * 给缺少主键的分录行补发主键,并校验分录挂在合法的分录表下。
* *
* @param slaveData 一张单据的分录数据,key 为分录表 tableId。 * <p>key 同时接受 tableId 与表名(见 {@link #resolveEntryTable})。查不到表或该表不是本表单
* @param newIdSet 本批次已生成的主键集合,用于防重复。 * 主表下挂的分录表时直接报错——原先静默跳过会让整段分录被无声丢弃。</p>
*
* @param slaveData 一张单据的分录数据。
* @param masterTable 表单主表,用于校验分录表归属。
* @param newIdSet 本批次后端发号生成的主键集合,用于防重复。
* @param usedIdSet 本批次出现过的全部主键(含调用方预生成的),用于查重。
* @return 出错信息;无错返回 null。
*/ */
private void makeupEntryPrimaryKey(JSONObject slaveData, Set<Long> newIdSet) { private String makeupEntryPrimaryKey(JSONObject slaveData, OnlineTable masterTable,
for (String tableIdStr : slaveData.keySet()) { Set<Long> newIdSet, Set<String> usedIdSet) {
OnlineTable entryTable = onlineTableService.getOnlineTableFromCache(this.convertToLongId(tableIdStr)); for (String entryKey : slaveData.keySet()) {
if (entryTable == null || entryTable.getPrimaryKeyColumn() == null) { OnlineTable entryTable = this.resolveEntryTable(entryKey);
if (entryTable == null) {
return "的表分键 [" + entryKey + "] 不是有效的表(应为分录表的 tableId 或表名),请检查!";
}
if (!this.isEntryTableOf(masterTable, entryTable)) {
return "的表分键 [" + entryKey + "] 不是当前表单的分录表,请检查!";
}
if (entryTable.getPrimaryKeyColumn() == null) {
continue; continue;
} }
String primaryKeyName = entryTable.getPrimaryKeyColumn().getColumnName(); String primaryKeyName = entryTable.getPrimaryKeyColumn().getColumnName();
Object entryValue = slaveData.get(tableIdStr); Object entryValue = slaveData.get(entryKey);
// 分录数据既可能是「行数组」,也可能是「单行对象」(一对一关系),两种都补齐主键。 // 分录数据既可能是「行数组」,也可能是「单行对象」(一对一关系),两种都补齐主键。
List<JSONObject> entryRowList = new LinkedList<>(); List<JSONObject> entryRowList = new LinkedList<>();
if (entryValue instanceof JSONArray) { if (entryValue instanceof JSONArray) {
@ -1326,11 +1362,77 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
entryRowList.add((JSONObject) entryValue); entryRowList.add((JSONObject) entryValue);
} }
for (JSONObject entryRow : entryRowList) { for (JSONObject entryRow : entryRowList) {
if (entryRow.get(primaryKeyName) == null) { // 已有主键必须保留(BOTP 转换预发号的分录 id 已被转换关系表引用),只给缺主键的行补号。
if (this.isBlankPrimaryKey(entryRow, primaryKeyName)) {
entryRow.put(primaryKeyName, this.generatePrimaryKey(entryTable.getPrimaryKeyColumn(), newIdSet)); 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());
} }
/** /**

Loading…
Cancel
Save