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} 对称:

+ * + * + * @author chenchuchuan + * @since 2026-10-22 + */ +@RestController +@RequestMapping("/botp/Pull") +public class PullController { + + @Autowired + private IPullService pullService; + + /** + * 按目标单据反查可用的转换规则(FR-5.9)。 + * + *

返回结果为空时,前端不应渲染「选单」入口(FR-5.1)。

+ * + *

目标单可能是在线表单,也可能是路由表单,前端手上有什么就传什么,按下面的优先级匹配:

+ *
    + *
  1. {@code targetRouteInterface} —— 路由界面 menuId;
  2. + *
  3. {@code targetRouterName} —— 路由页面路径(反查 xy_sys_menu);
  4. + *
  5. {@code targetFormId} —— 在线表单 id;
  6. + *
  7. {@code targetBillType} —— 单据表名。
  8. + *
+ * + * @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)。 + * + *

目标单据可能是在线表单,也可能是路由表单,两者在前端的标识不同, + * 所以提供四个入参,前端手上有什么就传什么(按下面的优先级依次匹配):

+ *
    + *
  1. {@code targetRouteInterface} —— 路由界面 menuId(路由表单场景最直接);
  2. + *
  3. {@code targetRouterName} —— 路由页面路径,反查 xy_sys_menu 得到 menuId;
  4. + *
  5. {@code targetFormId} —— 在线表单 id(OnlineForm.formId);
  6. + *
  7. {@code targetBillType} —— 单据表名,与推式 {@code getPushBillList} 一致。
  8. + *
+ * + * @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; + } + + /** + * 按目标单据查可用规则。 + * + *

目标单可能是在线表单,也可能是路由表单,两者在前端的标识不同,按下面的优先级依次尝试:

+ *
    + *
  1. 路由界面 menuId —— 规则上配的「下游路由界面」直接就是它;
  2. + *
  3. 路由页面路径 —— 反查 xy_sys_menu 得到 menuId,再按 1 查;
  4. + *
  5. 在线表单 id / 表名 —— 反查 OnlFormHead,按 target_entity_id 查。
  6. + *
+ * + * @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));