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

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

+ */ + private Map batchSavePayload; } diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java index 233bbab..7004d74 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/ConvertOrchestrator.java @@ -159,10 +159,146 @@ public class ConvertOrchestrator { if (autoSaveOn) { triggerSaveWriteBack(result, destFormHead); } + if (!autoSaveOn) { + result.setBatchSavePayload(this.buildBatchSavePayload(result)); + } return result; } /** + * 组装 executePluginBatch 的完整入参。 + * + *

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

+ * + *

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

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

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

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

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

+ * + * @param editFormId 目标编辑表单 id。 + * @return 按钮名;找不到返回 null。 + */ + private String resolveSaveButtonName(Long editFormId) { + OnlineForm editForm = onlineFormService.getOnlineFormFromCache(editFormId); + if (editForm == null || StringUtils.isBlank(editForm.getWidgetJson())) { + return null; + } + JSONObject widgetJson = JSON.parseObject(editForm.getWidgetJson()); + // 保存按钮的插件在 pc 与 mobile 下配置一致,取 pc 即可。 + JSONObject pc = widgetJson.getJSONObject("pc"); + if (pc == null) { + return null; + } + JSONArray operationList = pc.getJSONArray("operationList"); + if (operationList == null) { + return null; + } + for (int i = 0; i < operationList.size(); i++) { + JSONObject operation = operationList.getJSONObject(i); + if (!Boolean.TRUE.equals(operation.getBoolean("enabled"))) { + continue; + } + JSONArray pluginList = operation.getJSONArray("pluginList"); + if (pluginList == null) { + continue; + } + for (int j = 0; j < pluginList.size(); j++) { + JSONObject plugin = pluginList.getJSONObject(j); + if (Boolean.TRUE.equals(plugin.getBoolean("isEnable")) + && SAVE_PLUGIN_CLASS.equals(plugin.getString("class"))) { + return operation.getString("name"); + } + } + } + return null; + } + + /** + * 保存插件类名,与 executePluginBatch 的校验口径保持一致。 + */ + private static final String SAVE_PLUGIN_CLASS = "apelet.common.online.plugin.pre.SaveOperationServicePlugIn"; + + /** * 解析规则:显式版本取快照执行,未传入取库中当前数据(即最新生效版本)。 * * @param request 转换请求。 diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java index 0bc78e5..57ac0c7 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java @@ -1193,7 +1193,9 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } List masterList = new LinkedList<>(); List slaveList = new LinkedList<>(); - // 同一批次里发号生成的主键集合,用于防御生成器回绕或配置问题导致的重复主键。 + // 防重复用:已经出现在本批数据里的主键(含调用方预生成的,如 BOTP 转换预发号的 id)。 + Set usedIdSet = new HashSet<>(); + // 同一批次里由后端发号生成的主键集合,用于防御生成器回绕导致的重复主键。 Set newIdSet = new HashSet<>(); String primaryKeyName = onlineTable.getPrimaryKeyColumn().getColumnName(); for (int i = 0; i < masterArray.size(); i++) { @@ -1202,11 +1204,32 @@ 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"); + } + // 已有主键必须保留: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); @@ -1301,19 +1324,32 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } /** - * 给缺少主键的分录行补发主键,使每张单据的分录落到各自的表头下。 + * 给缺少主键的分录行补发主键,并校验分录挂在合法的分录表下。 + * + *

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

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