Browse Source

feat(botp): 新增拉式选单功能及反写规则版本回滚支持

- 新增 IPullService 接口及 PullController,按目标单据反查可用转换规则
- 新增 PullRuleDto 承载选单对话框所需源单列表表单、数据源等参数
- 新增 PullServiceImpl 实现多路径匹配(路由menuId/路由路径/在线表单id/表名)
- 新增 ResolvedWriteBackRule 实体封装解析后的规则、公式、业务分录及版本号
- ConvertRelationEntry 新增 writebackRuleVersion 字段,正向执行时记录所用版本
- 反写回滚时按关联关系记录的版本从快照表加载规则,确保用旧公式退旧数据
- 修复超额检查条件对目标单求值导致源单字段取不到的问题
- 修复业务规则回写表头时误用分录行 id 导致更新不到目标行的问题
- 新增单表单据无分录时的「单据↔单据」配对退化逻辑
- 优化插件加载,避免每条公式重复查询数据库
dev-lml
chenchuchuan 1 week ago
parent
commit
833f8cb63e
  1. 95
      common/common-botp/src/main/java/apelet/common/botp/controller/PullController.java
  2. 132
      common/common-botp/src/main/java/apelet/common/botp/dto/PullRuleDto.java
  3. 14
      common/common-botp/src/main/java/apelet/common/botp/model/ConvertRelationEntry.java
  4. 37
      common/common-botp/src/main/java/apelet/common/botp/service/IPullService.java
  5. 241
      common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java
  6. 42
      common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/ResolvedWriteBackRule.java
  7. 288
      common/common-botp/src/main/java/apelet/common/botp/service/impl/PullServiceImpl.java
  8. 33
      common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java

95
common/common-botp/src/main/java/apelet/common/botp/controller/PullController.java

@ -0,0 +1,95 @@ @@ -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;
/**
* 拉式生成(选单)接口。
*
* <p>与推式的 {@code /botp/ConvertRule/getPushBillList} + {@code /convert} 对称:</p>
* <ul>
* <li>这里只负责「查规则」,并把前端调在线表单列表接口所需的 formId / datasourceId / 变量名一并返回;</li>
* <li>候选源单查询由前端直接调在线表单的列表接口,不另开数据出口;</li>
* <li>用户选中之后,前端拿规则 id + 选中的源单 id 调 {@code /botp/ConvertRule/convert},
* 与下推走同一条转换链路(分单、合并、事务、写转换关系、反写全部一致)。</li>
* </ul>
*
* @author chenchuchuan
* @since 2026-10-22
*/
@RestController
@RequestMapping("/botp/Pull")
public class PullController {
@Autowired
private IPullService pullService;
/**
* 按目标单据反查可用的转换规则(FR-5.9)。
*
* <p>返回结果为空时,前端不应渲染「选单」入口(FR-5.1)。</p>
*
* <p>目标单可能是在线表单,也可能是路由表单,前端手上有什么就传什么,按下面的优先级匹配:</p>
* <ol>
* <li>{@code targetRouteInterface} —— 路由界面 menuId;</li>
* <li>{@code targetRouterName} —— 路由页面路径(反查 xy_sys_menu);</li>
* <li>{@code targetFormId} —— 在线表单 id;</li>
* <li>{@code targetBillType} —— 单据表名。</li>
* </ol>
*
* @param body 请求体。
* @return 可用的转换规则列表。
*/
@PostMapping("/ruleList")
@Schema(description = "按目标单据反查可用的转换规则(拉式选单)")
public ResponseResult<?> ruleList(@RequestBody Map<String, Object> 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<String, Object> 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<String, Object> body, String key) {
String value = toStr(body, key);
if (value == null) {
return null;
}
try {
return Long.valueOf(value);
} catch (NumberFormatException e) {
return null;
}
}
}

132
common/common-botp/src/main/java/apelet/common/botp/dto/PullRuleDto.java

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
package apelet.common.botp.dto;
import lombok.Data;
/**
* 拉式生成的可选转换规则(FR-5.9)。
*
* <p>与推式的 {@code getPushBillList} 对称,但方向相反:那边是「我这个源单能推到谁」,
* 这边是「我这个目标单能从谁拉上来」。</p>
*
* @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;
}

14
common/common-botp/src/main/java/apelet/common/botp/model/ConvertRelationEntry.java

@ -71,4 +71,18 @@ public class ConvertRelationEntry extends BaseModel { @@ -71,4 +71,18 @@ public class ConvertRelationEntry extends BaseModel {
@Schema(description = "反写前的源单字段原值,仅覆盖方式使用,供撤销回滚恢复")
private java.math.BigDecimal beforeValue;
/**
* 本次反写实际使用的<b>反写规则</b>版本。
*
* <p>撤销 / 弃审时按这个版本去 reverse_writing_rules_ver 取快照执行,
* 保证「怎么加的就怎么退」——反写规则改版后不会用新公式去回退旧数据。</p>
*
* <p><b>已知限制</b>:一条关联关系只存一个版本。同一对源/目标配了多条反写规则、
* 且它们的版本不同时,后执行的会覆盖先执行的。实践中同一对单据通常只配一条反写规则。</p>
*
* <p>对应 DDL:convert_relation_entry 需要新增 writeback_rule_version 列。</p>
*/
@Schema(description = "本次反写使用的反写规则版本,供撤销回滚按同一版本执行")
private Integer writebackRuleVersion;
}

37
common/common-botp/src/main/java/apelet/common/botp/service/IPullService.java

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
package apelet.common.botp.service;
import apelet.common.botp.dto.PullRuleDto;
import java.util.List;
/**
* 拉式生成(选单)服务。
*
* <p>只负责「按目标单据反查可用规则」。候选源单查询由前端直接调在线表单的列表接口,
* 后端不另开数据出口——这样列定义、数据权限、脱敏全部复用在线表单那一套。</p>
*
* @author chenchuchuan
* @since 2026-10-22
*/
public interface IPullService {
/**
* 按目标单据反查可用的转换规则(FR-5.9)。
*
* <p>目标单据可能是<b>在线表单</b>,也可能是<b>路由表单</b>,两者在前端的标识不同,
* 所以提供四个入参,前端手上有什么就传什么(按下面的优先级依次匹配):</p>
* <ol>
* <li>{@code targetRouteInterface} —— 路由界面 menuId(路由表单场景最直接);</li>
* <li>{@code targetRouterName} —— 路由页面路径,反查 xy_sys_menu 得到 menuId;</li>
* <li>{@code targetFormId} —— 在线表单 id(OnlineForm.formId);</li>
* <li>{@code targetBillType} —— 单据表名,与推式 {@code getPushBillList} 一致。</li>
* </ol>
*
* @param targetBillType 目标单表名,可空。
* @param targetFormId 目标单的在线表单 id,可空。
* @param targetRouteInterface 目标单的路由界面 menuId,可空。
* @param targetRouterName 目标单的路由页面路径,可空。
* @return 可用的转换规则列表;没有时返回空列表,前端据此决定「选单」入口是否显示。
*/
List<PullRuleDto> getPullRuleList(String targetBillType, Long targetFormId, Long targetRouteInterface, String targetRouterName);
}

241
common/common-botp/src/main/java/apelet/common/botp/service/execute/WriteBackOrchestrator.java

@ -16,6 +16,8 @@ import apelet.common.botp.service.IConvertPluginEntryService; @@ -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; @@ -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 @@ -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 { @@ -149,6 +154,11 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
private void executeRule(ReverseWritingRules rule, ObjectValue targetBill, OnlFormHead targetHead, BotpWriteBackRequest request) {
List<ConvertRelation> relations = locateRelations(targetHead, request);
List<ConvertRelationEntry> 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 { @@ -159,13 +169,161 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
rule.getId(), request.getTargetTableName(), request.getTargetBillId());
return;
}
List<WriteBackPlugIn> plugins = loadPlugins(rule.getId());
WriteBackContext context = buildContext(rule, targetBill, request.isRollback());
// 回滚时优先按关联关系上记录的版本取快照执行,保证「怎么加的就怎么退」
ResolvedWriteBackRule resolved = resolveRule(rule, entries, request.isRollback());
List<WriteBackPlugIn> 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);
}
}
/**
* 解析本次执行用的规则。
*
* <p>回滚时若关联关系上记录了版本、且与当前版本不同,就按记录版本取快照执行——
* 否则反写规则改版后,老单据的撤销会用新公式去退旧数据。</p>
*
* @param current 当前规则(库里最新版本)。
* @param entries 关联关系。
* @param rollback 是否回滚方向。
* @return 解析结果。
*/
private ResolvedWriteBackRule resolveRule(ReverseWritingRules current, List<ConvertRelationEntry> 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<ReverseWritingFormula>()
.eq(ReverseWritingFormula::getEnable, 1)
.eq(ReverseWritingFormula::getRulesId, current.getId())
.orderByAsc(ReverseWritingFormula::getExecOrder)));
resolved.setBusinesses(reverseWritingBusinessService.list(new LambdaQueryWrapper<ReverseWritingBusiness>()
.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 <T> 类型。
* @return 列表,不会返回 null。
*/
private <T> List<T> parseSection(JSONArray array, Class<T> clazz) {
return array == null ? new ArrayList<>() : array.toJavaList(clazz);
}
/**
* 取关联关系上记录的反写规则版本。
*
* @param entries 关联关系。
* @return 版本号;没有记录时返回 null。
*/
private Integer firstRecordedVersion(List<ConvertRelationEntry> entries) {
for (ConvertRelationEntry entry : entries) {
if (entry.getWritebackRuleVersion() != null) {
return entry.getWritebackRuleVersion();
}
}
return null;
}
/**
* 记录本次反写实际使用的规则版本(正向执行时)。
*
* <p>按目标单一次性更新,不逐行 update。</p>
*
* @param resolved 解析结果。
* @param entries 关联关系。
*/
private void recordRuleVersion(ResolvedWriteBackRule resolved, List<ConvertRelationEntry> entries) {
if (resolved.getVersion() == null || entries.isEmpty()) {
return;
}
Set<String> 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<ConvertRelationEntry>()
.in(ConvertRelationEntry::getTargetId, targetIds)
.set(ConvertRelationEntry::getWritebackRuleVersion, resolved.getVersion()));
}
/**
* 把单据级关联关系转成「单据 ↔ 单据」的配对。
*
* <p>单表单据没有分录,转换时不会写 convert_relation_entry。这里把单据 id 同时当作
* 「源行 id / 目标行 id」,后续按行取数的逻辑就能原样复用。</p>
*
* @param relations 单据级关联关系。
* @return 配对列表。
*/
private List<ConvertRelationEntry> toBillLevelPairs(List<ConvertRelation> relations) {
List<ConvertRelationEntry> 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 { @@ -215,15 +373,12 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
* <p>分支表达式形如 {@code 源单表.源单表.字段 = $fun$(参数)} 或 {@code 源单表.字段 = '常量'}:
* 等号左边是回写位置(源单),右边是取值(函数 / 字段引用 / 字面量)。</p>
*
* @param rule 反写规则。
* @param entries 关联关系。
* @param context 反写上下文。
* @param resolved 解析后的规则(含业务规则分录)。
* @param entries 关联关系。
* @param context 反写上下文。
*/
private void execBusinessRules(ReverseWritingRules rule, List<ConvertRelationEntry> entries, WriteBackContext context) {
List<ReverseWritingBusiness> businesses = reverseWritingBusinessService.list(new LambdaQueryWrapper<ReverseWritingBusiness>()
.eq(ReverseWritingBusiness::getEnable, 1)
.eq(ReverseWritingBusiness::getRulesId, rule.getId()));
for (ReverseWritingBusiness business : businesses) {
private void execBusinessRules(ResolvedWriteBackRule resolved, List<ConvertRelationEntry> 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 { @@ -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<ConvertRelationEntry> entries, WriteBackContext context) {
List<ReverseWritingFormula> formulas = reverseWritingFormulaService.list(new LambdaQueryWrapper<ReverseWritingFormula>()
.eq(ReverseWritingFormula::getEnable, 1)
.eq(ReverseWritingFormula::getRulesId, rule.getId())
.orderByAsc(ReverseWritingFormula::getExecOrder));
for (ReverseWritingFormula formula : formulas) {
private void execFormulas(ResolvedWriteBackRule resolved, List<ConvertRelationEntry> entries, WriteBackContext context, List<WriteBackPlugIn> 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 { @@ -295,8 +455,9 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
* @param formula 反写公式。
* @param entries 关联关系。
* @param context 反写上下文。
* @param plugins 已加载的插件实例。
*/
private void applyFormula(ReverseWritingFormula formula, List<ConvertRelationEntry> entries, WriteBackContext context) {
private void applyFormula(ReverseWritingFormula formula, List<ConvertRelationEntry> entries, WriteBackContext context, List<WriteBackPlugIn> 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 { @@ -304,7 +465,6 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
throw new BotpMappingException("反写公式配置不完整,公式=" + formula.getFormulaEnglish()
+ ",源单字段=" + formula.getSourceSingleField());
}
List<WriteBackPlugIn> 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 { @@ -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 { @@ -349,12 +509,28 @@ public class WriteBackOrchestrator implements BotpReverseWriteFacade {
* @param context 反写上下文(已填 newValue)。
* @return 超额返回 true;未配置检查公式时返回 false。
*/
private boolean excessHit(WriteBackContext context) {
/**
* 超额检查:对「本次写入后的值」求值,为真即判定超额。
*
* <p>求值上下文是<b>源分录行</b>,并且在求值前把算出的新值放进去——这样条件里
* 「已下推数量 + 本次数量 &gt; 申请数量」这类引用才能拿到写入后的结果。
* 原先拿目标单求值,源单字段一个都取不到,条件永远不成立。</p>
*
* <p>条件里的字段引用请写成 {@code 分录表.分录表.字段} 形式,与反写公式的
* {@code sourceSingleField} 保持一致。</p>
*
* @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 { @@ -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);

42
common/common-botp/src/main/java/apelet/common/botp/service/execute/entity/ResolvedWriteBackRule.java

@ -0,0 +1,42 @@ @@ -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;
/**
* 解析后的反写规则:规则主表 + 公式分录 + 业务规则分录,外加实际使用的版本号。
*
* <p>未指定版本时取自业务表当前数据;撤销回滚且关联关系上记录了版本时,取自
* reverse_writing_rules_ver 快照——这样才能用「当初写进去的那版规则」把值退回来。</p>
*
* @author chenchuchuan
* @since 2026-10-22
*/
@Data
public class ResolvedWriteBackRule {
/**
* 规则主表。
*/
private ReverseWritingRules rule;
/**
* 反写公式分录(已启用的)。
*/
private List<ReverseWritingFormula> formulas = new ArrayList<>();
/**
* 业务规则分录(已启用的)。
*/
private List<ReverseWritingBusiness> businesses = new ArrayList<>();
/**
* 本次实际使用的规则版本。
*/
private Integer version;
}

288
common/common-botp/src/main/java/apelet/common/botp/service/impl/PullServiceImpl.java

@ -0,0 +1,288 @@ @@ -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;
/**
* 拉式生成(选单)服务实现。
*
* <p>只负责「按目标单据反查可用规则」,并把前端选单对话框需要的三项参数一并返回:
* 源单的列表表单 id、数据源 id、数据源变量名。列定义和数据由前端调在线表单的现有接口获取。</p>
*
* @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<PullRuleDto> getPullRuleList(String targetBillType, Long targetFormId, Long targetRouteInterface, String targetRouterName) {
List<ConvertRule> rules = queryTargetRules(targetBillType, targetFormId, targetRouteInterface, targetRouterName);
if (rules.isEmpty()) {
return Collections.emptyList();
}
Map<Long, DatasourceInfo> cache = new HashMap<>();
List<PullRuleDto> result = new ArrayList<>(rules.size());
for (ConvertRule rule : rules) {
result.add(toPullRuleDto(rule, cache));
}
return result;
}
/**
* 按目标单据查可用规则。
*
* <p>目标单可能是在线表单,也可能是路由表单,两者在前端的标识不同,按下面的优先级依次尝试:</p>
* <ol>
* <li>路由界面 menuId —— 规则上配的「下游路由界面」直接就是它;</li>
* <li>路由页面路径 —— 反查 xy_sys_menu 得到 menuId,再按 1 查;</li>
* <li>在线表单 id / 表名 —— 反查 OnlFormHead,按 target_entity_id 查。</li>
* </ol>
*
* @param targetBillType 目标单表名,可空。
* @param targetFormId 在线表单 id,可空。
* @param targetRouteInterface 路由界面 menuId,可空。
* @param targetRouterName 路由页面路径,可空。
* @return 规则列表;解析不到时返回空列表。
*/
private List<ConvertRule> queryTargetRules(String targetBillType, Long targetFormId, Long targetRouteInterface, String targetRouterName) {
Long routeMenuId = targetRouteInterface != null ? targetRouteInterface : resolveMenuIdByRouterName(targetRouterName);
if (routeMenuId != null) {
return convertRuleMapper.selectList(new LambdaQueryWrapper<ConvertRule>()
.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<ConvertRule>()
.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<SysMenuqq>()
.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<OnlFormHead>().eq("table_name", table.getTableName()));
}
if (StringUtils.isBlank(targetBillType)) {
return null;
}
return onlFormHeadMapper.selectOne(new QueryWrapper<OnlFormHead>().eq("table_name", targetBillType));
}
/**
* 组装一条拉式规则的可选项。
*
* @param rule 转换规则。
* @param cache 表单 id → 数据源信息 的缓存(一个目标单可能挂多条规则)。
* @return 前端用的 DTO。
*/
private PullRuleDto toPullRuleDto(ConvertRule rule, Map<Long, DatasourceInfo> 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;
}
/**
* 解析路由表单的页面路径。
*
* <p>只有界面类型 = 1(路由表单)时才去查 {@code xy_sys_menu};在线表单直接用 formId,不需要这一步。</p>
*
* @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();
}
/**
* 兜底:规则没配「上游在线列表界面」时,按表名取第一个查询表单。
*
* <p>一张表可能有多个查询表单,这里取到的不一定是要用的那个,所以记 WARN 提示补配置。</p>
*
* @param tableName 源单表名。
* @param ruleId 转换规则 id。
* @return 列表表单 id;没有查询表单时返回 null。
*/
private Long fallbackFormId(String tableName, Long ruleId) {
List<OnlineForm> 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<Long, DatasourceInfo> 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;
}
}

33
common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java

@ -836,14 +836,7 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -836,14 +836,7 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
// 源单关闭标记:反写规则的「源单行关闭条件」成立时置 1,撤销/弃审不满足条件时置回 0。
// 挂在主表上是「整单关闭」语义——下推量与回写量相等即关闭该源单,不再出现在选单候选里
// (可在转换规则的「选单过滤」中引用 closed_flag = 0)。
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);
list.add(closedFlag);
list.add(buildClosedFlagField());
}
@ -867,6 +860,11 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -867,6 +860,11 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
list.add(seq);
}
// 单表(无分录)同样可以作为源单被下推,关闭标记就挂在它自己的表上
if (StringUtils.isNotEmpty(metaType) && metaType.equals(CustomUtil.MetaType_Single)) {
list.add(buildClosedFlagField());
}
// //流程表单需要添加的参数
// if (StringUtils.isNotEmpty(type)&&"0".equals(type)){
// OnlFormField proInsId = new OnlFormField();
@ -901,6 +899,25 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -901,6 +899,25 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
}
}
/**
* 构建「源单关闭标记」字段定义。
*
* <p>主表与单表都要带:反写规则的「源单行关闭条件」成立时置 1,
* 撤销/弃审不满足条件时置回 0。挂单据表(而不是分录表)是整单关闭语义。</p>
*
* @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<OnlFormHead> list = this.list(new LambdaQueryWrapper<OnlFormHead>().eq(OnlFormHead::getTableName, tableName));

Loading…
Cancel
Save