From 833f8cb63e6c47fe487ab2bc42127afc39c1b558 Mon Sep 17 00:00:00 2001
From: chenchuchuan <13554600537@163.com>
Date: Fri, 23 Oct 2026 13:41:20 +0800
Subject: [PATCH] =?UTF-8?q?feat(botp):=20=E6=96=B0=E5=A2=9E=E6=8B=89?=
=?UTF-8?q?=E5=BC=8F=E9=80=89=E5=8D=95=E5=8A=9F=E8=83=BD=E5=8F=8A=E5=8F=8D?=
=?UTF-8?q?=E5=86=99=E8=A7=84=E5=88=99=E7=89=88=E6=9C=AC=E5=9B=9E=E6=BB=9A?=
=?UTF-8?q?=E6=94=AF=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 IPullService 接口及 PullController,按目标单据反查可用转换规则
- 新增 PullRuleDto 承载选单对话框所需源单列表表单、数据源等参数
- 新增 PullServiceImpl 实现多路径匹配(路由menuId/路由路径/在线表单id/表名)
- 新增 ResolvedWriteBackRule 实体封装解析后的规则、公式、业务分录及版本号
- ConvertRelationEntry 新增 writebackRuleVersion 字段,正向执行时记录所用版本
- 反写回滚时按关联关系记录的版本从快照表加载规则,确保用旧公式退旧数据
- 修复超额检查条件对目标单求值导致源单字段取不到的问题
- 修复业务规则回写表头时误用分录行 id 导致更新不到目标行的问题
- 新增单表单据无分录时的「单据↔单据」配对退化逻辑
- 优化插件加载,避免每条公式重复查询数据库
---
.../common/botp/controller/PullController.java | 95 +++++++
.../java/apelet/common/botp/dto/PullRuleDto.java | 132 ++++++++++
.../common/botp/model/ConvertRelationEntry.java | 14 +
.../apelet/common/botp/service/IPullService.java | 37 +++
.../service/execute/WriteBackOrchestrator.java | 241 ++++++++++++++---
.../execute/entity/ResolvedWriteBackRule.java | 42 +++
.../common/botp/service/impl/PullServiceImpl.java | 288 +++++++++++++++++++++
.../service/impl/OnlFormHeadServiceImpl.java | 33 ++-
8 files changed, 844 insertions(+), 38 deletions(-)
create mode 100644 common/common-botp/src/main/java/apelet/common/botp/controller/PullController.java
create mode 100644 common/common-botp/src/main/java/apelet/common/botp/dto/PullRuleDto.java
create mode 100644 common/common-botp/src/main/java/apelet/common/botp/service/IPullService.java
create mode 100644 common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/ResolvedWriteBackRule.java
create mode 100644 common/common-botp/src/main/java/apelet/common/botp/service/impl/PullServiceImpl.java
diff --git a/common/common-botp/src/main/java/apelet/common/botp/controller/PullController.java b/common/common-botp/src/main/java/apelet/common/botp/controller/PullController.java
new file mode 100644
index 0000000..4c8cebb
--- /dev/null
+++ b/common/common-botp/src/main/java/apelet/common/botp/controller/PullController.java
@@ -0,0 +1,95 @@
+package apelet.common.botp.controller;
+
+import apelet.common.botp.service.IPullService;
+import apelet.common.core.object.ResponseResult;
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 拉式生成(选单)接口。
+ *
+ *
与推式的 {@code /botp/ConvertRule/getPushBillList} + {@code /convert} 对称:
+ *
+ * - 这里只负责「查规则」,并把前端调在线表单列表接口所需的 formId / datasourceId / 变量名一并返回;
+ * - 候选源单查询由前端直接调在线表单的列表接口,不另开数据出口;
+ * - 用户选中之后,前端拿规则 id + 选中的源单 id 调 {@code /botp/ConvertRule/convert},
+ * 与下推走同一条转换链路(分单、合并、事务、写转换关系、反写全部一致)。
+ *
+ *
+ * @author chenchuchuan
+ * @since 2026-10-22
+ */
+@RestController
+@RequestMapping("/botp/Pull")
+public class PullController {
+
+ @Autowired
+ private IPullService pullService;
+
+ /**
+ * 按目标单据反查可用的转换规则(FR-5.9)。
+ *
+ * 返回结果为空时,前端不应渲染「选单」入口(FR-5.1)。
+ *
+ * 目标单可能是在线表单,也可能是路由表单,前端手上有什么就传什么,按下面的优先级匹配:
+ *
+ * - {@code targetRouteInterface} —— 路由界面 menuId;
+ * - {@code targetRouterName} —— 路由页面路径(反查 xy_sys_menu);
+ * - {@code targetFormId} —— 在线表单 id;
+ * - {@code targetBillType} —— 单据表名。
+ *
+ *
+ * @param body 请求体。
+ * @return 可用的转换规则列表。
+ */
+ @PostMapping("/ruleList")
+ @Schema(description = "按目标单据反查可用的转换规则(拉式选单)")
+ public ResponseResult> ruleList(@RequestBody Map body) {
+ return ResponseResult.success(pullService.getPullRuleList(
+ toStr(body, "targetBillType"),
+ toLong(body, "targetFormId"),
+ toLong(body, "targetRouteInterface"),
+ toStr(body, "targetRouterName")));
+ }
+
+ /**
+ * 从请求体取字符串参数。
+ *
+ * @param body 请求体。
+ * @param key 字段名。
+ * @return 值;为空时返回 null。
+ */
+ private String toStr(Map body, String key) {
+ if (body == null) {
+ return null;
+ }
+ Object value = body.get(key);
+ return value == null || StringUtils.isBlank(String.valueOf(value)) ? null : String.valueOf(value);
+ }
+
+ /**
+ * 从请求体取长整型参数。
+ *
+ * @param body 请求体。
+ * @param key 字段名。
+ * @return 值;为空或非法时返回 null。
+ */
+ private Long toLong(Map body, String key) {
+ String value = toStr(body, key);
+ if (value == null) {
+ return null;
+ }
+ try {
+ return Long.valueOf(value);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+}
diff --git a/common/common-botp/src/main/java/apelet/common/botp/dto/PullRuleDto.java b/common/common-botp/src/main/java/apelet/common/botp/dto/PullRuleDto.java
new file mode 100644
index 0000000..a60e2a6
--- /dev/null
+++ b/common/common-botp/src/main/java/apelet/common/botp/dto/PullRuleDto.java
@@ -0,0 +1,132 @@
+package apelet.common.botp.dto;
+
+import lombok.Data;
+
+/**
+ * 拉式生成的可选转换规则(FR-5.9)。
+ *
+ * 与推式的 {@code getPushBillList} 对称,但方向相反:那边是「我这个源单能推到谁」,
+ * 这边是「我这个目标单能从谁拉上来」。
+ *
+ * @author chenchuchuan
+ * @since 2026-10-22
+ */
+@Data
+public class PullRuleDto {
+
+ /**
+ * 转换规则 id。
+ */
+ private Long ruleId;
+
+ /**
+ * 规则编码。
+ */
+ private String ruleNumber;
+
+ /**
+ * 规则名称。
+ */
+ private String ruleName;
+
+ /**
+ * 源单元数据 id。
+ */
+ private String sourceEntityId;
+
+ /**
+ * 源单表名 —— 选单对话框按这张表查候选源单。
+ */
+ private String sourceTable;
+
+ /**
+ * 源单显示名。
+ */
+ private String sourceTableTxt;
+
+ /**
+ * 源单的在线表单 id(列表表单),**含兜底**:优先取规则上配置的「上游在线列表界面」,
+ * 没配时按表名取第一个查询表单。前端拿它调 {@code /onlineForm/render} 取列定义。
+ */
+ private Long sourceFormId;
+
+ /**
+ * 规则上配置的「上游界面类型」:1 路由表单 / 2 在线表单(原始配置值,未兜底)。
+ */
+ private String upInterfaceType;
+
+ /**
+ * 上游路由界面 id(= xy_sys_menu.menu_id),仅当 upInterfaceType=1 时有值。
+ */
+ private Long upRouteInterface;
+
+ /**
+ * 上游路由页面路径(= xy_sys_menu.form_router_name),仅当 upInterfaceType=1 时去查。
+ */
+ private String upFormRouterName;
+
+ /**
+ * 规则上配置的「上游在线列表界面」formId(原始配置值,未兜底;可能为空)。
+ */
+ private Long upListInterface;
+
+ /**
+ * 规则上配置的「上游在线编辑界面」formId(可能为空)。
+ */
+ private Long upEditInterface;
+
+ /**
+ * 规则上配置的「下游界面类型」:1 路由表单 / 2 在线表单。
+ */
+ private String downInterfaceType;
+
+ /**
+ * 下游路由界面 id(= xy_sys_menu.menu_id),仅当 downInterfaceType=1 时有值。
+ */
+ private Long downRouteInterface;
+
+ /**
+ * 下游路由页面路径(= xy_sys_menu.form_router_name),仅当 downInterfaceType=1 时去查。
+ */
+ private String downFormRouterName;
+
+ /**
+ * 规则上配置的「下游在线列表界面」formId(可能为空)。
+ */
+ private Long downListInterface;
+
+ /**
+ * 规则上配置的「下游在线编辑界面」formId(可能为空)。
+ */
+ private Long downEditInterface;
+
+ /**
+ * 源单的数据源 id。
+ */
+ private Long datasourceId;
+
+ /**
+ * 源单的数据源变量名(在线表单列表接口路径里的 {@code datasourceVariableName})。
+ */
+ private String datasourceVariableName;
+
+ /**
+ * 选单过滤条件 —— 对话框里只读展示,不可就地修改(FR-5.4)。
+ */
+ private String selectFilterJson;
+
+ /**
+ * 分单策略 —— 前端据此预测「选中 N 条会生成几张目标单」。
+ */
+ private String splitMergeStrategy;
+
+ /**
+ * 生成后打开的目标单页面(路由路径或在线表单 id)。
+ */
+ private String billEditUI;
+
+ /**
+ * 目标单页面类型:1 路由表单 / 2 在线表单。
+ */
+ private String billEditUIType;
+}
diff --git a/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRelationEntry.java b/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRelationEntry.java
index 8844c9a..7585f0a 100644
--- a/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRelationEntry.java
+++ b/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRelationEntry.java
@@ -71,4 +71,18 @@ public class ConvertRelationEntry extends BaseModel {
@Schema(description = "反写前的源单字段原值,仅覆盖方式使用,供撤销回滚恢复")
private java.math.BigDecimal beforeValue;
+ /**
+ * 本次反写实际使用的反写规则版本。
+ *
+ * 撤销 / 弃审时按这个版本去 reverse_writing_rules_ver 取快照执行,
+ * 保证「怎么加的就怎么退」——反写规则改版后不会用新公式去回退旧数据。
+ *
+ * 已知限制:一条关联关系只存一个版本。同一对源/目标配了多条反写规则、
+ * 且它们的版本不同时,后执行的会覆盖先执行的。实践中同一对单据通常只配一条反写规则。
+ *
+ * 对应 DDL:convert_relation_entry 需要新增 writeback_rule_version 列。
+ */
+ @Schema(description = "本次反写使用的反写规则版本,供撤销回滚按同一版本执行")
+ private Integer writebackRuleVersion;
+
}
diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/IPullService.java b/common/common-botp/src/main/java/apelet/common/botp/service/IPullService.java
new file mode 100644
index 0000000..2a6f9af
--- /dev/null
+++ b/common/common-botp/src/main/java/apelet/common/botp/service/IPullService.java
@@ -0,0 +1,37 @@
+package apelet.common.botp.service;
+
+import apelet.common.botp.dto.PullRuleDto;
+
+import java.util.List;
+
+/**
+ * 拉式生成(选单)服务。
+ *
+ * 只负责「按目标单据反查可用规则」。候选源单查询由前端直接调在线表单的列表接口,
+ * 后端不另开数据出口——这样列定义、数据权限、脱敏全部复用在线表单那一套。
+ *
+ * @author chenchuchuan
+ * @since 2026-10-22
+ */
+public interface IPullService {
+
+ /**
+ * 按目标单据反查可用的转换规则(FR-5.9)。
+ *
+ * 目标单据可能是在线表单,也可能是路由表单,两者在前端的标识不同,
+ * 所以提供四个入参,前端手上有什么就传什么(按下面的优先级依次匹配):
+ *
+ * - {@code targetRouteInterface} —— 路由界面 menuId(路由表单场景最直接);
+ * - {@code targetRouterName} —— 路由页面路径,反查 xy_sys_menu 得到 menuId;
+ * - {@code targetFormId} —— 在线表单 id(OnlineForm.formId);
+ * - {@code targetBillType} —— 单据表名,与推式 {@code getPushBillList} 一致。
+ *
+ *
+ * @param targetBillType 目标单表名,可空。
+ * @param targetFormId 目标单的在线表单 id,可空。
+ * @param targetRouteInterface 目标单的路由界面 menuId,可空。
+ * @param targetRouterName 目标单的路由页面路径,可空。
+ * @return 可用的转换规则列表;没有时返回空列表,前端据此决定「选单」入口是否显示。
+ */
+ List getPullRuleList(String targetBillType, Long targetFormId, Long targetRouteInterface, String targetRouterName);
+}
diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java
index 106c888..1f97004 100644
--- a/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java
+++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java
@@ -16,6 +16,8 @@ import apelet.common.botp.service.IConvertPluginEntryService;
import apelet.common.botp.service.IReverseWritingBusinessService;
import apelet.common.botp.service.IReverseWritingFormulaService;
import apelet.common.botp.service.IReverseWritingRulesService;
+import apelet.common.botp.service.execute.entity.ResolvedWriteBackRule;
+import apelet.common.botp.util.RuleSnapshotUtil;
import apelet.common.botp.util.RuleValueCalculator;
import apelet.common.core.botp.BotpReverseWriteFacade;
import apelet.common.core.botp.BotpWriteBackRequest;
@@ -32,8 +34,10 @@ import apelet.common.online.rule.model.RuleEvalContext;
import apelet.common.orm.impl.Selector;
import apelet.common.orm.impl.SelectorItem;
import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -44,6 +48,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.math.BigDecimal;
import java.util.*;
+import java.util.stream.Collectors;
/**
* 反写编排器:保存 / 审核 / 撤销 / 弃审四个入口共用的唯一实现。
@@ -149,6 +154,11 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
private void executeRule(ReverseWritingRules rule, ObjectValue targetBill, OnlFormHead targetHead, BotpWriteBackRequest request) {
List relations = locateRelations(targetHead, request);
List entries = relations.isEmpty() ? Collections.emptyList() : loadRelationEntries(relations);
+ if (entries.isEmpty() && !relations.isEmpty()) {
+ // 单表单据(无分录)不会产生分录级关联关系,退化成「单据 ↔ 单据」配对:
+ // 此时分行 id 就是单据 id,公式里的 表.字段 直接落在单据表上。
+ entries = toBillLevelPairs(relations);
+ }
if (entries.isEmpty()) {
// 关系记录缺失时回退读单据上的 srcbillid / srcbillentryid(迁移期兼容)
entries = fallbackBySrcBillId(targetBill);
@@ -159,13 +169,161 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
rule.getId(), request.getTargetTableName(), request.getTargetBillId());
return;
}
- List plugins = loadPlugins(rule.getId());
- WriteBackContext context = buildContext(rule, targetBill, request.isRollback());
+ // 回滚时优先按关联关系上记录的版本取快照执行,保证「怎么加的就怎么退」
+ ResolvedWriteBackRule resolved = resolveRule(rule, entries, request.isRollback());
+ List plugins = loadPlugins(resolved.getRule().getId());
+ WriteBackContext context = buildContext(resolved.getRule(), targetBill, request.isRollback());
invoke(plugins, context, WriteBackHookEnum.BEFORE_RULE);
- execBusinessRules(rule, entries, context);
- execFormulas(rule, entries, context);
- closeSourceRows(rule, entries, context);
+ execBusinessRules(resolved, entries, context);
+ execFormulas(resolved, entries, context, plugins);
+ closeSourceRows(resolved.getRule(), entries, context);
registerAfterCommit(plugins, context);
+ if (!request.isRollback()) {
+ recordRuleVersion(resolved, entries);
+ }
+ }
+
+ /**
+ * 解析本次执行用的规则。
+ *
+ * 回滚时若关联关系上记录了版本、且与当前版本不同,就按记录版本取快照执行——
+ * 否则反写规则改版后,老单据的撤销会用新公式去退旧数据。
+ *
+ * @param current 当前规则(库里最新版本)。
+ * @param entries 关联关系。
+ * @param rollback 是否回滚方向。
+ * @return 解析结果。
+ */
+ private ResolvedWriteBackRule resolveRule(ReverseWritingRules current, List entries, boolean rollback) {
+ Integer recorded = firstRecordedVersion(entries);
+ if (rollback && recorded != null && !recorded.equals(current.getRuleVersion())) {
+ ResolvedWriteBackRule fromSnapshot = loadSnapshot(current.getId(), recorded);
+ if (fromSnapshot != null) {
+ log.info("反写回滚按记录版本执行。规则id={},记录版本={},当前版本={}", current.getId(), recorded, current.getRuleVersion());
+ return fromSnapshot;
+ }
+ }
+ ResolvedWriteBackRule resolved = new ResolvedWriteBackRule();
+ resolved.setRule(current);
+ resolved.setVersion(current.getRuleVersion());
+ resolved.setFormulas(reverseWritingFormulaService.list(new LambdaQueryWrapper()
+ .eq(ReverseWritingFormula::getEnable, 1)
+ .eq(ReverseWritingFormula::getRulesId, current.getId())
+ .orderByAsc(ReverseWritingFormula::getExecOrder)));
+ resolved.setBusinesses(reverseWritingBusinessService.list(new LambdaQueryWrapper()
+ .eq(ReverseWritingBusiness::getEnable, 1)
+ .eq(ReverseWritingBusiness::getRulesId, current.getId())));
+ return resolved;
+ }
+
+ /**
+ * 按版本读反写规则快照。
+ *
+ * @param ruleId 规则 id。
+ * @param version 版本号。
+ * @return 解析结果;快照缺失或内容不完整时返回 null(调用方退回当前版本)。
+ */
+ private ResolvedWriteBackRule loadSnapshot(Long ruleId, Integer version) {
+ ReverseWritingRulesVer snapshot = reverseWritingRulesService.getVersionSnapshot(ruleId, version);
+ if (snapshot == null || StringUtils.isBlank(snapshot.getSnapshotJson())) {
+ log.warn("反写规则快照不存在,退回当前版本执行。规则id={},版本={}", ruleId, version);
+ return null;
+ }
+ JSONObject json = JSON.parseObject(snapshot.getSnapshotJson());
+ ReverseWritingRules rule = JSON.parseObject(json.getString(RuleSnapshotUtil.SECTION_RULE), ReverseWritingRules.class);
+ if (rule == null) {
+ log.warn("反写规则快照内容不完整(缺少 rule 分区),退回当前版本执行。规则id={},版本={}", ruleId, version);
+ return null;
+ }
+ ResolvedWriteBackRule resolved = new ResolvedWriteBackRule();
+ resolved.setRule(rule);
+ resolved.setFormulas(parseSection(json.getJSONArray(RuleSnapshotUtil.SECTION_FORMULAS), ReverseWritingFormula.class).stream()
+ .filter(f -> f.getEnable() == 1)
+ .sorted(Comparator.comparing(ReverseWritingFormula::getExecOrder, Comparator.nullsLast(Comparator.naturalOrder())))
+ .collect(Collectors.toList()));
+ resolved.setBusinesses(parseSection(json.getJSONArray(RuleSnapshotUtil.SECTION_BUSINESSES), ReverseWritingBusiness.class).stream()
+ .filter(b -> b.getEnable() == 1)
+ .collect(Collectors.toList()));
+ resolved.setVersion(snapshot.getRuleVersion());
+ return resolved;
+ }
+
+ /**
+ * 解析快照里的子表分区。
+ *
+ * @param array 分区数组,可空。
+ * @param clazz 目标类型。
+ * @param 类型。
+ * @return 列表,不会返回 null。
+ */
+ private List parseSection(JSONArray array, Class clazz) {
+ return array == null ? new ArrayList<>() : array.toJavaList(clazz);
+ }
+
+ /**
+ * 取关联关系上记录的反写规则版本。
+ *
+ * @param entries 关联关系。
+ * @return 版本号;没有记录时返回 null。
+ */
+ private Integer firstRecordedVersion(List entries) {
+ for (ConvertRelationEntry entry : entries) {
+ if (entry.getWritebackRuleVersion() != null) {
+ return entry.getWritebackRuleVersion();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 记录本次反写实际使用的规则版本(正向执行时)。
+ *
+ * 按目标单一次性更新,不逐行 update。
+ *
+ * @param resolved 解析结果。
+ * @param entries 关联关系。
+ */
+ private void recordRuleVersion(ResolvedWriteBackRule resolved, List entries) {
+ if (resolved.getVersion() == null || entries.isEmpty()) {
+ return;
+ }
+ Set targetIds = new LinkedHashSet<>();
+ for (ConvertRelationEntry entry : entries) {
+ if (StringUtils.isNotBlank(entry.getTargetId())) {
+ targetIds.add(entry.getTargetId());
+ }
+ }
+ if (targetIds.isEmpty()) {
+ return;
+ }
+ convertRelationEntryMapper.update(null, new LambdaUpdateWrapper()
+ .in(ConvertRelationEntry::getTargetId, targetIds)
+ .set(ConvertRelationEntry::getWritebackRuleVersion, resolved.getVersion()));
+ }
+
+ /**
+ * 把单据级关联关系转成「单据 ↔ 单据」的配对。
+ *
+ * 单表单据没有分录,转换时不会写 convert_relation_entry。这里把单据 id 同时当作
+ * 「源行 id / 目标行 id」,后续按行取数的逻辑就能原样复用。
+ *
+ * @param relations 单据级关联关系。
+ * @return 配对列表。
+ */
+ private List toBillLevelPairs(List relations) {
+ List pairs = new ArrayList<>(relations.size());
+ for (ConvertRelation relation : relations) {
+ if (StringUtils.isBlank(relation.getSourceId())) {
+ continue;
+ }
+ ConvertRelationEntry pair = new ConvertRelationEntry();
+ pair.setSourceId(relation.getSourceId());
+ pair.setSourceEntryId(relation.getSourceId());
+ pair.setTargetId(relation.getTargetId());
+ pair.setTargeEntryId(relation.getTargetId());
+ pairs.add(pair);
+ }
+ return pairs;
}
/**
@@ -215,15 +373,12 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
* 分支表达式形如 {@code 源单表.源单表.字段 = $fun$(参数)} 或 {@code 源单表.字段 = '常量'}:
* 等号左边是回写位置(源单),右边是取值(函数 / 字段引用 / 字面量)。
*
- * @param rule 反写规则。
- * @param entries 关联关系。
- * @param context 反写上下文。
+ * @param resolved 解析后的规则(含业务规则分录)。
+ * @param entries 关联关系。
+ * @param context 反写上下文。
*/
- private void execBusinessRules(ReverseWritingRules rule, List entries, WriteBackContext context) {
- List businesses = reverseWritingBusinessService.list(new LambdaQueryWrapper()
- .eq(ReverseWritingBusiness::getEnable, 1)
- .eq(ReverseWritingBusiness::getRulesId, rule.getId()));
- for (ReverseWritingBusiness business : businesses) {
+ private void execBusinessRules(ResolvedWriteBackRule resolved, List entries, WriteBackContext context) {
+ for (ReverseWritingBusiness business : resolved.getBusinesses()) {
boolean hit = business.getPreconditionsEnglish() == null
|| conditionHit(business.getPreconditionsEnglish(), business.getPreconditionsJson(), context.getTargetBillData());
String expression = hit ? business.getEstablishedEnglish() : business.getNotEstablishedEnglish();
@@ -259,29 +414,34 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
if (value == null) {
return;
}
+ // 回写位置可能是源单【表头】或【分录】:表头写源单 id,分录写源分录行 id。
+ // 原先一律用 sourceEntryId,目标是表头时会按分录行 id 去更新表头行,写错行或压根更新不到。
+ String sourceHeaderTable = resolveTableName(String.valueOf(context.getRule().getSourceDocuments()));
+ boolean headerTarget = sourceHeaderTable != null && sourceHeaderTable.equals(targetRef[0]);
for (ConvertRelationEntry entry : entries) {
- writeSourceRow(targetRef[0], entry.getSourceEntryId(), targetRef[1], value);
+ String rowId = headerTarget ? entry.getSourceId() : entry.getSourceEntryId();
+ if (StringUtils.isBlank(rowId)) {
+ continue;
+ }
+ writeSourceRow(targetRef[0], rowId, targetRef[1], value);
}
}
/**
* 逐条执行反写公式,按 exec_order 升序。
*
- * @param rule 反写规则。
- * @param entries 关联关系。
- * @param context 反写上下文。
+ * @param resolved 解析后的规则(含公式分录)。
+ * @param entries 关联关系。
+ * @param context 反写上下文。
+ * @param plugins 已加载的插件实例(复用,避免每条公式都查一次库)。
*/
- private void execFormulas(ReverseWritingRules rule, List entries, WriteBackContext context) {
- List formulas = reverseWritingFormulaService.list(new LambdaQueryWrapper()
- .eq(ReverseWritingFormula::getEnable, 1)
- .eq(ReverseWritingFormula::getRulesId, rule.getId())
- .orderByAsc(ReverseWritingFormula::getExecOrder));
- for (ReverseWritingFormula formula : formulas) {
+ private void execFormulas(ResolvedWriteBackRule resolved, List entries, WriteBackContext context, List plugins) {
+ for (ReverseWritingFormula formula : resolved.getFormulas()) {
context.setFormula(formula);
if (!conditionHit(formula.getConditionEnglish(), formula.getConditionJson(), context.getTargetBillData())) {
continue;
}
- applyFormula(formula, entries, context);
+ applyFormula(formula, entries, context, plugins);
}
}
@@ -295,8 +455,9 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
* @param formula 反写公式。
* @param entries 关联关系。
* @param context 反写上下文。
+ * @param plugins 已加载的插件实例。
*/
- private void applyFormula(ReverseWritingFormula formula, List entries, WriteBackContext context) {
+ 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());
@@ -304,7 +465,6 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
throw new BotpMappingException("反写公式配置不完整,公式=" + formula.getFormulaEnglish()
+ ",源单字段=" + formula.getSourceSingleField());
}
- List plugins = loadPlugins(context.getRule().getId());
int scale = resolveScale(sourceRef[0], sourceRef[1]);
for (ConvertRelationEntry entry : entries) {
ObjectValue targetRow = queryRow(targetRef[0], entry.getTargeEntryId());
@@ -328,7 +488,7 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
context.setCommitted(false);
invoke(plugins, context, WriteBackHookEnum.AFTER_CALC);
invoke(plugins, context, WriteBackHookEnum.BEFORE_EXCESS);
- context.setExcess(excessHit(context));
+ context.setExcess(excessHit(context, sourceRow, sourceRef[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()
@@ -349,12 +509,28 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
* @param context 反写上下文(已填 newValue)。
* @return 超额返回 true;未配置检查公式时返回 false。
*/
- private boolean excessHit(WriteBackContext context) {
+ /**
+ * 超额检查:对「本次写入后的值」求值,为真即判定超额。
+ *
+ * 求值上下文是源分录行,并且在求值前把算出的新值放进去——这样条件里
+ * 「已下推数量 + 本次数量 > 申请数量」这类引用才能拿到写入后的结果。
+ * 原先拿目标单求值,源单字段一个都取不到,条件永远不成立。
+ *
+ * 条件里的字段引用请写成 {@code 分录表.分录表.字段} 形式,与反写公式的
+ * {@code sourceSingleField} 保持一致。
+ *
+ * @param context 反写上下文(已填 newValue)。
+ * @param sourceRow 源分录行。
+ * @param sourceField 本次写入的源单字段名。
+ * @return 超额返回 true;未配置检查公式时返回 false。
+ */
+ private boolean excessHit(WriteBackContext context, ObjectValue sourceRow, String sourceField) {
String expression = context.getRule().getExcessCheckJson();
- if (StringUtils.isBlank(expression)) {
+ if (StringUtils.isBlank(expression) || sourceRow == null) {
return false;
}
- return conditionHit(expression, null, context.getTargetBillData());
+ sourceRow.put(sourceField, context.getNewValue());
+ return conditionHit(expression, null, sourceRow);
}
/**
@@ -451,6 +627,11 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
* @param beforeValue 反写前的原值。
*/
private void recordBeforeValue(ConvertRelationEntry entry, BigDecimal beforeValue) {
+ if (entry.getId() == null) {
+ // 单据级配对(单表单据)没有真实的 convert_relation_entry 行,无处记录原值
+ log.warn("单表单据没有分录级关联记录,覆盖方式的原值未记录,撤销时无法恢复。源单id={}", entry.getSourceId());
+ return;
+ }
ConvertRelationEntry update = new ConvertRelationEntry();
update.setId(entry.getId());
update.setBeforeValue(beforeValue);
diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/ResolvedWriteBackRule.java b/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/ResolvedWriteBackRule.java
new file mode 100644
index 0000000..58e9800
--- /dev/null
+++ b/common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/ResolvedWriteBackRule.java
@@ -0,0 +1,42 @@
+package apelet.common.botp.service.execute.entity;
+
+import apelet.common.botp.model.ReverseWritingBusiness;
+import apelet.common.botp.model.ReverseWritingFormula;
+import apelet.common.botp.model.ReverseWritingRules;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 解析后的反写规则:规则主表 + 公式分录 + 业务规则分录,外加实际使用的版本号。
+ *
+ * 未指定版本时取自业务表当前数据;撤销回滚且关联关系上记录了版本时,取自
+ * reverse_writing_rules_ver 快照——这样才能用「当初写进去的那版规则」把值退回来。
+ *
+ * @author chenchuchuan
+ * @since 2026-10-22
+ */
+@Data
+public class ResolvedWriteBackRule {
+
+ /**
+ * 规则主表。
+ */
+ private ReverseWritingRules rule;
+
+ /**
+ * 反写公式分录(已启用的)。
+ */
+ private List formulas = new ArrayList<>();
+
+ /**
+ * 业务规则分录(已启用的)。
+ */
+ private List businesses = new ArrayList<>();
+
+ /**
+ * 本次实际使用的规则版本。
+ */
+ private Integer version;
+}
diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/impl/PullServiceImpl.java b/common/common-botp/src/main/java/apelet/common/botp/service/impl/PullServiceImpl.java
new file mode 100644
index 0000000..3e96b37
--- /dev/null
+++ b/common/common-botp/src/main/java/apelet/common/botp/service/impl/PullServiceImpl.java
@@ -0,0 +1,288 @@
+package apelet.common.botp.service.impl;
+
+import apelet.common.botp.dao.ConvertRuleMapper;
+import apelet.common.botp.dto.PullRuleDto;
+import apelet.common.botp.model.ConvertRule;
+import apelet.common.botp.model.SysMenuqq;
+import apelet.common.botp.model.constant.BotpRuleConstant;
+import apelet.common.botp.service.IPullService;
+import apelet.common.botp.service.ISysMenuqqService;
+import apelet.common.generator.dao.OnlFormHeadMapper;
+import apelet.common.generator.model.OnlFormHead;
+import apelet.common.online.model.OnlineDatasource;
+import apelet.common.online.model.OnlineForm;
+import apelet.common.online.model.OnlineTable;
+import apelet.common.online.model.constant.FormType;
+import apelet.common.online.service.OnlineDatasourceService;
+import apelet.common.online.service.OnlineFormService;
+import apelet.common.online.service.OnlineTableService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 拉式生成(选单)服务实现。
+ *
+ * 只负责「按目标单据反查可用规则」,并把前端选单对话框需要的三项参数一并返回:
+ * 源单的列表表单 id、数据源 id、数据源变量名。列定义和数据由前端调在线表单的现有接口获取。
+ *
+ * @author chenchuchuan
+ * @since 2026-10-22
+ */
+@Service
+@Slf4j
+public class PullServiceImpl implements IPullService {
+
+ @Autowired
+ private ConvertRuleMapper convertRuleMapper;
+ @Autowired
+ private OnlFormHeadMapper onlFormHeadMapper;
+ @Autowired
+ private ISysMenuqqService sysMenuqqService;
+ @Autowired
+ private OnlineTableService onlineTableService;
+ @Autowired
+ private OnlineDatasourceService onlineDatasourceService;
+ @Autowired
+ private OnlineFormService onlineFormService;
+
+ @Override
+ public List getPullRuleList(String targetBillType, Long targetFormId, Long targetRouteInterface, String targetRouterName) {
+ List rules = queryTargetRules(targetBillType, targetFormId, targetRouteInterface, targetRouterName);
+ if (rules.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Map cache = new HashMap<>();
+ List result = new ArrayList<>(rules.size());
+ for (ConvertRule rule : rules) {
+ result.add(toPullRuleDto(rule, cache));
+ }
+ return result;
+ }
+
+ /**
+ * 按目标单据查可用规则。
+ *
+ * 目标单可能是在线表单,也可能是路由表单,两者在前端的标识不同,按下面的优先级依次尝试:
+ *
+ * - 路由界面 menuId —— 规则上配的「下游路由界面」直接就是它;
+ * - 路由页面路径 —— 反查 xy_sys_menu 得到 menuId,再按 1 查;
+ * - 在线表单 id / 表名 —— 反查 OnlFormHead,按 target_entity_id 查。
+ *
+ *
+ * @param targetBillType 目标单表名,可空。
+ * @param targetFormId 在线表单 id,可空。
+ * @param targetRouteInterface 路由界面 menuId,可空。
+ * @param targetRouterName 路由页面路径,可空。
+ * @return 规则列表;解析不到时返回空列表。
+ */
+ private List queryTargetRules(String targetBillType, Long targetFormId, Long targetRouteInterface, String targetRouterName) {
+ Long routeMenuId = targetRouteInterface != null ? targetRouteInterface : resolveMenuIdByRouterName(targetRouterName);
+ if (routeMenuId != null) {
+ return convertRuleMapper.selectList(new LambdaQueryWrapper()
+ .eq(ConvertRule::getEnabled, BotpRuleConstant.SWITCH_ON_VALUE)
+ .eq(ConvertRule::getDownRouteInterface, routeMenuId));
+ }
+ OnlFormHead targetHead = resolveHead(targetBillType, targetFormId);
+ if (targetHead == null) {
+ return Collections.emptyList();
+ }
+ return convertRuleMapper.selectList(new LambdaQueryWrapper()
+ .eq(ConvertRule::getEnabled, BotpRuleConstant.SWITCH_ON_VALUE)
+ .eq(ConvertRule::getTargetEntityId, String.valueOf(targetHead.getId())));
+ }
+
+ /**
+ * 按路由页面路径反查菜单 id。
+ *
+ * @param routerName 路由页面路径(xy_sys_menu.form_router_name)。
+ * @return 菜单 id;未传或查不到时返回 null。
+ */
+ private Long resolveMenuIdByRouterName(String routerName) {
+ if (StringUtils.isBlank(routerName)) {
+ return null;
+ }
+ SysMenuqq menu = sysMenuqqService.getOne(new LambdaQueryWrapper()
+ .eq(SysMenuqq::getFormRouterName, routerName)
+ .last("LIMIT 1"));
+ return menu == null ? null : menu.getMenuId();
+ }
+
+ /**
+ * 解析目标单元数据:支持按「表名」或「在线表单 id」两种方式。
+ *
+ * @param targetBillType 目标单表名,可空。
+ * @param targetFormId 在线表单 id,可空。
+ * @return 单元数据;解析不到时返回 null。
+ */
+ private OnlFormHead resolveHead(String targetBillType, Long targetFormId) {
+ if (targetFormId != null) {
+ OnlineForm form = onlineFormService.getById(targetFormId);
+ if (form == null || form.getMasterTableId() == null) {
+ log.warn("在线表单不存在或未绑定主表,formId={}", targetFormId);
+ return null;
+ }
+ OnlineTable table = onlineTableService.getById(form.getMasterTableId());
+ if (table == null) {
+ log.warn("在线表单绑定的主表不存在,formId={},tableId={}", targetFormId, form.getMasterTableId());
+ return null;
+ }
+ return onlFormHeadMapper.selectOne(new QueryWrapper().eq("table_name", table.getTableName()));
+ }
+ if (StringUtils.isBlank(targetBillType)) {
+ return null;
+ }
+ return onlFormHeadMapper.selectOne(new QueryWrapper().eq("table_name", targetBillType));
+ }
+
+ /**
+ * 组装一条拉式规则的可选项。
+ *
+ * @param rule 转换规则。
+ * @param cache 表单 id → 数据源信息 的缓存(一个目标单可能挂多条规则)。
+ * @return 前端用的 DTO。
+ */
+ private PullRuleDto toPullRuleDto(ConvertRule rule, Map cache) {
+ PullRuleDto dto = new PullRuleDto();
+ dto.setRuleId(rule.getId());
+ dto.setRuleNumber(rule.getNumber());
+ dto.setRuleName(rule.getName());
+ dto.setSourceEntityId(rule.getSourceEntityId());
+ dto.setSelectFilterJson(rule.getSelectFilterJson());
+ dto.setSplitMergeStrategy(rule.getSplitMergeStrategy());
+ // 上游/下游的界面类型与列表界面 formId 原样透出,前端按需使用
+ dto.setUpInterfaceType(rule.getUpInterfaceType());
+ dto.setUpRouteInterface(rule.getUpRouteInterface());
+ dto.setUpFormRouterName(resolveFormRouterName(rule.getUpInterfaceType(), rule.getUpRouteInterface()));
+ dto.setUpListInterface(rule.getUpListInterface());
+ dto.setUpEditInterface(rule.getUpEditInterface());
+ dto.setDownInterfaceType(rule.getDownInterfaceType());
+ dto.setDownRouteInterface(rule.getDownRouteInterface());
+ dto.setDownFormRouterName(resolveFormRouterName(rule.getDownInterfaceType(), rule.getDownRouteInterface()));
+ dto.setDownListInterface(rule.getDownListInterface());
+ dto.setDownEditInterface(rule.getDownEditInterface());
+ OnlFormHead srcHead = onlFormHeadMapper.selectById(rule.getSourceEntityId());
+ if (srcHead != null) {
+ dto.setSourceTable(srcHead.getTableName());
+ dto.setSourceTableTxt(srcHead.getTableTxt());
+ }
+ // 源单的列表表单:路由表单没有 OnlineForm,直接用路由页面;在线表单取规则上配置的「上游在线列表界面」
+ Long sourceFormId = null;
+ if (!"1".equals(rule.getUpInterfaceType())) {
+ sourceFormId = rule.getUpListInterface();
+ if (sourceFormId == null && srcHead != null) {
+ sourceFormId = fallbackFormId(srcHead.getTableName(), rule.getId());
+ }
+ }
+ dto.setSourceFormId(sourceFormId);
+ DatasourceInfo info = resolveDatasource(sourceFormId, srcHead == null ? null : srcHead.getTableName(), cache);
+ dto.setDatasourceId(info.datasourceId);
+ dto.setDatasourceVariableName(info.variableName);
+ // 生成后打开哪:路由表单给路由页面,在线表单给编辑表单 id
+ if ("1".equals(rule.getDownInterfaceType())) {
+ dto.setBillEditUI(resolveFormRouterName(rule.getDownInterfaceType(), rule.getDownRouteInterface()));
+ dto.setBillEditUIType("1");
+ } else {
+ dto.setBillEditUI(String.valueOf(rule.getDownEditInterface()));
+ dto.setBillEditUIType("2");
+ }
+ return dto;
+ }
+
+ /**
+ * 解析路由表单的页面路径。
+ *
+ * 只有界面类型 = 1(路由表单)时才去查 {@code xy_sys_menu};在线表单直接用 formId,不需要这一步。
+ *
+ * @param interfaceType 界面类型:1 路由表单 / 2 在线表单。
+ * @param routeInterface 路由界面 id(= xy_sys_menu.menu_id)。
+ * @return {@code form_router_name};非路由表单、未配置或查不到时返回 null。
+ */
+ private String resolveFormRouterName(String interfaceType, Long routeInterface) {
+ if (!"1".equals(interfaceType) || routeInterface == null) {
+ return null;
+ }
+ SysMenuqq menu = sysMenuqqService.getById(routeInterface);
+ return menu == null ? null : menu.getFormRouterName();
+ }
+
+ /**
+ * 兜底:规则没配「上游在线列表界面」时,按表名取第一个查询表单。
+ *
+ * 一张表可能有多个查询表单,这里取到的不一定是要用的那个,所以记 WARN 提示补配置。
+ *
+ * @param tableName 源单表名。
+ * @param ruleId 转换规则 id。
+ * @return 列表表单 id;没有查询表单时返回 null。
+ */
+ private Long fallbackFormId(String tableName, Long ruleId) {
+ List forms = onlineFormService.queryByTableName(tableName, FormType.QUERY);
+ if (forms == null || forms.isEmpty()) {
+ log.warn("转换规则未配置「上游在线列表界面」,且表 {} 下没有查询表单,规则id={}", tableName, ruleId);
+ return null;
+ }
+ Long formId = forms.get(0).getFormId();
+ log.warn("转换规则未配置「上游在线列表界面」,回退到表 {} 的第一个查询表单 formId={},规则id={}。建议在规则上补配置。",
+ tableName, formId, ruleId);
+ return formId;
+ }
+
+ /**
+ * 解析源单的数据源:优先按列表表单反查,拿不到再按表名兜底。
+ *
+ * @param formId 源单列表表单 id,可空。
+ * @param tableName 源单表名,可空。
+ * @param cache 缓存。
+ * @return 数据源信息,字段可能为空。
+ */
+ private DatasourceInfo resolveDatasource(Long formId, String tableName, Map cache) {
+ if (formId != null) {
+ DatasourceInfo cached = cache.get(formId);
+ if (cached != null) {
+ return cached;
+ }
+ }
+ DatasourceInfo info = new DatasourceInfo();
+ OnlineTable table = null;
+ if (formId != null) {
+ OnlineForm form = onlineFormService.getById(formId);
+ if (form != null && form.getMasterTableId() != null) {
+ table = onlineTableService.getById(form.getMasterTableId());
+ }
+ }
+ if (table == null && StringUtils.isNotBlank(tableName)) {
+ table = onlineTableService.queryByTableName(tableName);
+ }
+ if (table != null) {
+ OnlineDatasource datasource = onlineDatasourceService.getOnlineDatasourceByMasterTableId(table.getTableId());
+ if (datasource != null) {
+ info.datasourceId = datasource.getDatasourceId();
+ info.variableName = datasource.getVariableName();
+ }
+ }
+ if (info.datasourceId == null) {
+ log.warn("源单表未配置数据源,前端将无法查询候选源单。formId={},tableName={}", formId, tableName);
+ }
+ if (formId != null) {
+ cache.put(formId, info);
+ }
+ return info;
+ }
+
+ /**
+ * 数据源信息。
+ */
+ private static class DatasourceInfo {
+ private Long datasourceId;
+ private String variableName;
+ }
+}
diff --git a/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java b/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java
index 6967ee0..a4b789b 100644
--- a/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java
+++ b/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java
@@ -836,14 +836,7 @@ public class OnlFormHeadServiceImpl extends ServiceImpl主表与单表都要带:反写规则的「源单行关闭条件」成立时置 1,
+ * 撤销/弃审不满足条件时置回 0。挂单据表(而不是分录表)是整单关闭语义。
+ *
+ * @return 字段定义。
+ */
+ private OnlFormField buildClosedFlagField() {
+ OnlFormField closedFlag = new OnlFormField();
+ closedFlag.setFieldName("closed_flag");
+ closedFlag.setFieldRemark("源单关闭标记(1:已关闭 0:未关闭)");
+ closedFlag.setFieldType(CustomUtil.FieldType_int);
+ closedFlag.setRowDisabled(1);
+ closedFlag.setIsEmpty(1);
+ closedFlag.setIsDefault(1);
+ return closedFlag;
+ }
+
@Override
public Boolean checkTableName(String tableName) {
List list = this.list(new LambdaQueryWrapper().eq(OnlFormHead::getTableName, tableName));