供应商、名称这类文本字段无法做累加/扣减,反写语义只有「用目标单最新值覆盖源单」。
+ * 正向时把目标单原值写到源单并记下反写前原值;回滚时按记录的原值恢复。
+ *
+ * @param entry 关联关系。
+ * @param context 反写上下文。
+ * @param plugins 已加载的插件实例。
+ * @param writeRow 源单行(写入位置)。
+ * @param readRow 目标单行(取值来源)。
+ * @param writeRef [源单表, 源单列]。
+ * @param readRef [目标单表, 目标单列]。
+ * @param writeRowId 源单行 id。
+ */
+ private void syncNonNumericValue(ConvertRelationEntry entry, WriteBackContext context,
+ List plugins, ObjectValue writeRow, ObjectValue readRow,
+ String[] writeRef, String[] readRef, String writeRowId) {
+ Object currentValue = readRow.get(readRef[1]);
+ if (currentValue == null) {
+ log.warn("反写跳过:目标单字段为空,无法同步到源单。读取表={},列={}", readRef[0], readRef[1]);
+ return;
+ }
+ Object newValue;
+ if (context.isRollback()) {
+ // 回滚:恢复反写前记录的原值;历史数据没记原值时不覆盖(避免把源单写坏)。
+ if (entry.getBeforeValue() == null) {
+ log.warn("覆盖方式缺少反写前原值,无法回滚,跳过。源行id={}", writeRowId);
+ return;
+ }
+ newValue = entry.getBeforeValue();
+ } else {
+ newValue = currentValue;
+ }
+ context.setCommitted(false);
+ invoke(plugins, context, WriteBackHookEnum.AFTER_CALC);
+ writeSourceRow(writeRef[0], writeRowId, writeRef[1], newValue);
+ if (!context.isRollback()) {
+ // 文本原值记在 before_value 里:该列是 decimal,无法存文本,这里只记录数值型原值,
+ // 文本的回滚依赖上游业务重新同步,故此处不写记录并给出提示。
+ log.info("文本字段反写完成。源表={},列={},新值={};文本原值不入 before_value(该列仅存数值)",
+ writeRef[0], writeRef[1], newValue);
+ }
+ context.setCommitted(true);
+ }
+
+ /**
* 执行一条反写公式。
*
* 逐对处理关联关系:目标分录行的值作为「本次值」,源分录行的值作为「原值」,
@@ -459,43 +505,62 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
*/
private void applyFormula(ReverseWritingFormula formula, List entries, WriteBackContext context, List plugins) {
ReverseWriteModeEnum mode = ReverseWriteModeEnum.ofNullable(formula.getMode());
- String[] targetRef = splitRef(formula.getFormulaEnglish());
- String[] sourceRef = splitRef(formula.getSourceSingleField());
- if (targetRef == null || sourceRef == null) {
+ // 字段语义(以库中实际配置为准):
+ // formula(formulaEnglish) = 回写位置,指向「源单」(如 请购单.请购单.供应商)
+ // sourceSingleField = 取值位置,指向「目标单」(如 采购单.采购单.供应商)
+ // 因此下面 writeRef 是写入目标,readRef 是读取来源,切勿颠倒。
+ String[] writeRef = splitRef(formula.getFormulaEnglish());
+ String[] readRef = splitRef(formula.getSourceSingleField());
+ if (writeRef == null || readRef == null) {
throw new BotpMappingException("反写公式配置不完整,公式=" + formula.getFormulaEnglish()
+ ",源单字段=" + formula.getSourceSingleField());
}
- int scale = resolveScale(sourceRef[0], sourceRef[1]);
+ int scale = resolveScale(writeRef[0], writeRef[1]);
+ // 引用可能是「表头字段」也可能是「分录字段」:前者用单据 id 查,后者用分录行 id 查。
+ boolean writeIsHead = this.isHeadTable(writeRef[0]);
+ boolean readIsHead = this.isHeadTable(readRef[0]);
for (ConvertRelationEntry entry : entries) {
- ObjectValue targetRow = queryRow(targetRef[0], entry.getTargeEntryId());
- ObjectValue sourceRow = queryRow(sourceRef[0], entry.getSourceEntryId());
- if (targetRow == null || sourceRow == null) {
- log.warn("反写跳过:关联的分录行已不存在。目标分录id={},源分录id={}", entry.getTargeEntryId(), entry.getSourceEntryId());
+ // 写入侧 = 源单(请购单),读取侧 = 目标单(采购单)。
+ String writeRowId = writeIsHead ? entry.getSourceId() : entry.getSourceEntryId();
+ String readRowId = readIsHead ? entry.getTargetId() : entry.getTargeEntryId();
+ ObjectValue writeRow = queryRow(writeRef[0], writeRowId);
+ ObjectValue readRow = queryRow(readRef[0], readRowId);
+ if (writeRow == null || readRow == null) {
+ log.warn("反写跳过:关联的行已不存在。写入表={},写入行id={},读取表={},读取行id={}",
+ writeRef[0], writeRowId, readRef[0], readRowId);
+ continue;
+ }
+ // current = 本单最新值(读自目标单),original = 源单当前值(写入位置的现值)。
+ BigDecimal current = toBigDecimal(readRow.get(readRef[1]));
+ BigDecimal original = toBigDecimal(writeRow.get(writeRef[1]));
+ // 非数值字段(如供应商、名称等文本):数值运算无从谈起,按「直接同步」处理——
+ // 把目标单最新值原样写到源单,撤销时按 before_value 恢复。数值字段仍走原有计算。
+ if (current == null || original == null) {
+ this.syncNonNumericValue(entry, context, plugins, writeRow, readRow, writeRef, readRef, writeRowId);
continue;
}
- BigDecimal current = toBigDecimal(targetRow.get(targetRef[1]));
- BigDecimal original = toBigDecimal(sourceRow.get(sourceRef[1]));
context.setOriginalValue(original);
context.setCurrentValue(current);
BigDecimal newValue = context.isRollback()
? RuleValueCalculator.revert(original, current, mode, entry.getBeforeValue(), scale)
: RuleValueCalculator.apply(original, current, mode, scale);
if (newValue == null) {
- log.warn("覆盖方式缺少反写前原值,无法回滚,跳过。源分录id={}", entry.getSourceEntryId());
+ log.warn("覆盖方式缺少反写前原值,无法回滚,跳过。源行id={}", entry.getSourceEntryId());
continue;
}
context.setNewValue(newValue);
context.setCommitted(false);
invoke(plugins, context, WriteBackHookEnum.AFTER_CALC);
invoke(plugins, context, WriteBackHookEnum.BEFORE_EXCESS);
- context.setExcess(excessHit(context, sourceRow, sourceRef[1]));
+ context.setExcess(excessHit(context, writeRow, writeRef[1]));
invoke(plugins, context, WriteBackHookEnum.AFTER_EXCESS);
if (Boolean.TRUE.equals(context.getExcess()) && ExcessActionEnum.ofNullable(context.getRule().getExcessAction()) == ExcessActionEnum.REJECT) {
throw new BotpMappingException("反写超额,操作已取消。规则=" + context.getRule().getName()
- + ",源分录id=" + entry.getSourceEntryId()
+ + ",源行id=" + writeRowId
+ ",原值=" + original + ",本次值=" + current + ",计算后=" + newValue);
}
- writeSourceRow(sourceRef[0], entry.getSourceEntryId(), sourceRef[1], newValue);
+ // 写回源单(formula 指向的那张表)。
+ writeSourceRow(writeRef[0], writeRowId, writeRef[1], newValue);
if (!context.isRollback() && mode == ReverseWriteModeEnum.OVERWRITE) {
recordBeforeValue(entry, original);
}
@@ -632,10 +697,12 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
log.warn("单表单据没有分录级关联记录,覆盖方式的原值未记录,撤销时无法恢复。源单id={}", entry.getSourceId());
return;
}
- ConvertRelationEntry update = new ConvertRelationEntry();
- update.setId(entry.getId());
- update.setBeforeValue(beforeValue);
- convertRelationEntryMapper.updateById(update);
+ // 用 UpdateWrapper 显式 set:beforeValue 为 null 时(如源字段非数值、toBigDecimal 转换失败),
+ // updateById 会因 MyBatis-Plus 跳过 null 字段而生成没有 SET 的非法 SQL。
+ convertRelationEntryMapper.update(null,
+ new LambdaUpdateWrapper()
+ .eq(ConvertRelationEntry::getId, entry.getId())
+ .set(ConvertRelationEntry::getBeforeValue, beforeValue));
entry.setBeforeValue(beforeValue);
}
@@ -708,6 +775,22 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
}
/**
+ * 判断引用里的表名是不是「单据表头表」。
+ *
+ * 反写公式两种引用的行 id 来源不同:引用表头表时用单据 id,引用分录表时用分录行 id。
+ * 表头表在 onl_form_head 里有登记,分录表没有。
+ *
+ * @param tableName 表名。
+ * @return 是表头表返回 true。
+ */
+ private boolean isHeadTable(String tableName) {
+ if (StringUtils.isBlank(tableName)) {
+ return false;
+ }
+ return onlFormHeadService.getHeadTableNameCache(tableName) != null;
+ }
+
+ /**
* 按主键查一行数据。
*
* @param table 表名。
diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java
index 9249a34..cfbc0b3 100644
--- a/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java
+++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/BtpTransformResult.java
@@ -4,7 +4,9 @@ import apelet.common.core.object.ObjectCollection;
import lombok.Data;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
/**
* @author Soon
@@ -19,5 +21,11 @@ public class BtpTransformResult {
private List billRelationList = new ArrayList<>();
+ /**
+ * 分录「预发主键 → 落库后的真实主键」映射。
+ *
+ * ORM 插入分录时会重新发号,关系表必须存真实主键,否则反写按 targe_entry_id 查不到行。
+ */
+ private Map entryIdMap = new HashMap<>();
}
diff --git a/common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java b/common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java
new file mode 100644
index 0000000..079b02c
--- /dev/null
+++ b/common/common-core/src/main/java/apelet/common/core/botp/BotpConvertRelationFacade.java
@@ -0,0 +1,27 @@
+package apelet.common.core.botp;
+
+/**
+ * 转换关系落库门面(依赖反转用)。
+ *
+ * 为什么需要:转换关系表 {@code convert_relation} / {@code convert_relation_entry} 只在
+ * BOTP 转换的 autoSave 分支写入。当 {@code formStatus=2}(未落库、由前端手动保存)时,
+ * 转换阶段不会写关系,保存接口落库后必须补写,否则后续「保存即反写」「上查下查」都找不到来源单。
+ *
+ * 为什么放在 common-core:与 {@link BotpReverseWriteFacade} 同理——补写实现与实体都在
+ * common-botp,而补写时机在 common-online,直接依赖会成环。
+ *
+ * 调用方应通过 {@link BotpReverseWriteSupport#saveRelationQuietly} 调用,未挂载 botp 时静默跳过。
+ *
+ */
+public interface BotpConvertRelationFacade {
+
+ /**
+ * 按转换结果下发的关联关系补写转换关系表。
+ *
+ * 已存在的关联关系(按关联关系主键判重)直接跳过,保证重复保存不会产生重复关系。
+ *
+ * @param billRelationJson 单张目标单的关联关系 JSON 串,结构见 BillRelation。
+ * @param targetBillId 目标单落库后的真实主键,会覆盖 JSON 里的 targetId。
+ */
+ void saveBillRelation(String billRelationJson, Long targetBillId);
+}
diff --git a/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java b/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java
index 179e087..de72a35 100644
--- a/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java
+++ b/common/common-core/src/main/java/apelet/common/core/botp/BotpReverseWriteSupport.java
@@ -53,6 +53,27 @@ public final class BotpReverseWriteSupport {
}
/**
+ * 补写转换关系;当前应用未挂载 botp 时静默跳过。
+ *
+ * 用于 {@code formStatus=2}(未落库、前端手动保存)的场景:转换阶段没写 convert_relation,
+ * 保存落库后由这里补上,否则后续反写找不到来源单。
+ *
+ * @param billRelationJson 关联关系 JSON 串,为空时直接跳过。
+ * @param targetBillId 目标单落库后的真实主键。
+ */
+ public static void saveRelationQuietly(String billRelationJson, Long targetBillId) {
+ if (billRelationJson == null || billRelationJson.trim().isEmpty() || targetBillId == null) {
+ return;
+ }
+ BotpConvertRelationFacade facade = getFacadeQuietly(BotpConvertRelationFacade.class);
+ if (facade == null) {
+ log.debug("当前应用未挂载 BOTP 转换关系服务,跳过关系补写。目标单id={}", targetBillId);
+ return;
+ }
+ facade.saveBillRelation(billRelationJson, targetBillId);
+ }
+
+ /**
* 从 Spring 容器取反写门面实现,取不到返回 null(不抛异常)。
*
* ApplicationContextHolder 没有安全取法,这里同时兜住两类异常:容器里没有该 bean
@@ -61,8 +82,19 @@ public final class BotpReverseWriteSupport {
* @return 门面实现;未挂载时为 null。
*/
private static BotpReverseWriteFacade getFacadeQuietly() {
+ return getFacadeQuietly(BotpReverseWriteFacade.class);
+ }
+
+ /**
+ * 按类型安全取门面实现。
+ *
+ * @param facadeType 门面接口类型。
+ * @param 门面类型。
+ * @return 实现;未挂载时为 null。
+ */
+ private static T getFacadeQuietly(Class facadeType) {
try {
- return ApplicationContextHolder.getBean(BotpReverseWriteFacade.class);
+ return ApplicationContextHolder.getBean(facadeType);
} catch (BeansException | MyRuntimeException e) {
return null;
}
diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java
index b18c4a6..1cf5f0d 100644
--- a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java
+++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java
@@ -340,12 +340,14 @@ public interface OnlineOperationService {
*
* @param masterList 表头数据列表,主键已补齐。
* @param slaveList 分录数据列表,下标与表头对齐。
+ * @param relationList 转换关系列表,下标与表头对齐;元素可为 JSONObject 或 null。
* @param onlinePluginExecuteDto 操作按钮等参数。
* @param onlineForm 当前表单。
* @param sortDtoList 已解析并按执行顺序排好的插件列表。
* @return 操作结果。
*/
OperationResult executeBatchSaveInTransaction(List masterList, List slaveList,
+ List