# 低代码只能做单表 CRUD?我们一行代码没写,搭了个完整进销存 > 主从表、库存扣减、审批通过自动入库——低代码的三个传统翻车点,这次一个都没翻。 --- ## 写在前面 低代码平台有个经典的"三连翻车"剧本,做过业务系统的都熟: - **第一翻:主从表**。单表 CRUD 演示得很溜,一遇到"采购单 + 采购明细"这种主子结构,就开始让你"自定义开发"。 - **第二翻:业务逻辑**。字段增删改查是零代码,但"审批通过后扣库存"这种事,平台两手一摊:写代码吧。 - **第三翻:审批联动**。流程引擎是流程引擎,业务数据是业务数据,中间的缝靠人肉填。 所以低代码平台演示的时候都很好看,真到进销存这种场景就露馅——因为进销存恰好把三个翻车点占全了:单据全是主子结构,库存变更全是业务逻辑,每张单据都要走审批。 [Forge Admin](https://gitee.com/ForgeLab/forge-admin) 最近干了一件事:**用纯低代码方式,把一个完整的采购仓储模块搭了出来**——物料、供应商、仓库、采购、出库、调拨,10 张业务表、10 个业务对象、6 个应用入口,没有写任何 Vue 页面,也没有写任何 Java Controller。 这篇文章就拆它是怎么扛住这三个翻车点的。不讲概念,直接看实现。 --- ## 一、先立规矩:什么叫"零代码" 先把话说清楚,免得评论区吵架。 "零代码"不是说这个系统里没有代码——动作引擎、台账服务、运行时装配,这些**平台能力**当然是代码写的,而且写得很硬核。 "零代码"指的是:**交付这个进销存应用本身,没有产生一行业务代码**。没有 `PurchaseOrderController.java`,没有 `purchase-order.vue`。交付物是一组配置数据: ```text ai_lowcode_domain 业务领域(采购仓储) └── ai_business_suite 业务套件 └── ai_business_object 业务对象 ×10(物料/供应商/仓库/采购单/采购明细/...) └── ai_crud_config 运行配置(页面协议 → AiCrudPage 五件配置) └── designer_options.actions 业务动作(审批回调、入库、锁定...) └── ai_business_app 应用入口 ×6(RUNTIME 模式,指向 configKey) └── ai_business_binding 能力挂接(流程回调绑定) ``` 这些配置全部通过初始化 SQL 交付, `tenant_id = 1`。换句话说:**应用即数据**。这是判断一个低代码平台成色的分水岭——应用到底是"画出来的页面",还是一套可以版本化、可以迁移、可以审查的结构化资产。 > 【截图占位 1:应用中心 — "采购仓储"套件入口,能看到 6 个应用入口卡片】 --- ## 二、翻车点一:主从表 采购单长这样:主表是单据头(单号、供应商、仓库、备注),子表是采购明细行(物料、数量、单价)。这是业务系统里最普通的结构,也是低代码平台最常见的翻车现场。 ### 2.1 设计态:一份协议描述主子结构 Forge 的主从页面不是"画"出来的,是协议驱动的。`ai_crud_config.page_schema` 里的核心结构: ```json { "layoutType": "master-detail-crud", "primaryModelCode": "PW_PURCHASE_ORDER", "modelRefs": [ { "modelCode": "PW_PURCHASE_ORDER", "primary": true, "relations": [{ "relationType": "ONE_TO_MANY", "sourceField": "id", "targetField": "purchaseId" }] }, { "modelCode": "PW_PURCHASE_ORDER_ITEM", "primary": false, "props": { "saveMode": "merge", "recordSelector": { "...": "..." } } } ] } ``` 发布时,`LowcodeRuntimeConfigBuilder.buildRuntimeConfig()` 把这份协议编译成 AiCrudPage 的运行配置(searchSchema / columnsSchema / editSchema / apiConfig / options),主从结构编进 `options.masterDetailConfig`。运行页按 `configKey` 请求 `GET /ai/crud-config/render/{configKey}`,拿到 `layoutType = master-detail-crud` 后加载主从模板组件。 链路里没有一行是采购专用的——换成"订单 + 订单明细"、"报销单 + 费用明细",协议结构一模一样。 ### 2.2 运行态:两个硬细节 协议谁都会写,运行态的两个细节才见功力。 **细节一:记录选择器。** 明细行不是只能手敲。子表头部有"选择物料"按钮,弹出记录选择器——支持多字段关键词搜索、支持 `${formData.xxx}` 动态过滤(比如只显示当前供应商有报价的物料),选中的记录按 `fieldMappings` 映射后批量追加到子表。这个交互在手写页面里都经常被偷懒省掉。 **细节二:merge 保存。** 主子表保存最烦的问题:用户在编辑页删了一行明细,提交时怎么办? 简单粗暴的做法是"全删重插"(Forge 里叫 `replace` 模式,也是默认模式),但这对有审计要求的单据不可接受。采购明细用的是 `merge` 模式:前端删除一行时不物理移除,而是打上 `_deleted` 标记,提交时组织成这样的 payload: ```js { main: { /* 主表字段 */ }, children: { pw_purchase_order_item: [ { id: 101, _deleted: true }, // 删除(走逻辑删) { id: 102, quantity: 50 }, // 更新(先校验行归属) { materialId: 'MAT-003', quantity: 200 } // 无 id,新增 ] } } ``` 后端 `DynamicCrudService.mergeMasterDetailChildRows()` 按标记分流:`_deleted + 有 id` → 逻辑删除;无 id → 插入;有 id → **先校验这一行确实属于当前主记录**再更新。最后这条校验很关键——没有它,改个 id 就能改别人单据的明细行,这是主从表接口的经典越权漏洞。 > 【截图占位 2:采购单编辑页 — 主子表结构 + "选择物料"记录选择器弹窗】 --- ## 三、翻车点二:库存怎么算 这是全文的核心,也是低代码平台最心虚的地方。 先说 Forge 的立场:**业务逻辑不靠生成代码,也不靠脚本引擎,而是靠一个白名单制的"动作引擎"**。 ### 3.1 动作 = 一组有事务、有幂等的白名单步骤 一个业务动作(action)挂在业务对象上,类型为 `COMMAND`,由若干步骤(step)组成。步骤类型是**白名单**,目前六种: | 步骤类型 | 语义 | |----------|------| | `UPDATE_FIELD` | 更新当前/目标记录字段 | | `CREATE_RECORD` | 创建记录 | | `START_FLOW` | 发起审批流程 | | `SEND_MESSAGE` | 发送消息 | | `FOREACH` | 循环集合(如明细行),嵌套执行子步骤 | | `DOMAIN_ACTION` | 调用领域动作 SPI,首个实现是 `QUANTITY` 数量台账 | 为什么不做成"写一段 JS/Groovy 随便跑"?因为脚本引擎是个无底洞:没有权限边界、没有审计、没有事务保证、出了问题没法排查。白名单步骤每一项都有明确的语义、参数协议和权限校验,`BusinessActionExecutionService` 统一入口执行:**幂等检查(RUNNING 预占日志 + 请求摘要防并发)→ `TransactionTemplate` 单事务顺序执行 → 写执行日志**。宁可步骤类型少一点,也不把动作引擎做成任意脚本执行器。 ### 3.2 数量台账:库存不是字段,是账 很多系统里"库存"就是商品表上的一个 `stock` 字段,扣库存就是 `update stock = stock - ?`。这在演示里没问题,在生产里是灾难:并发超卖、重复提交重复扣、出了问题查无对证。 Forge 把数量做成了**台账**,三张表: - `ai_business_quantity_balance`:余额表(账户 + 项目 + 维度,当前数量、锁定数量) - `ai_business_quantity_ledger`:流水表(每笔变动,`idempotency_key` 唯一键) - `ai_business_quantity_lock`:锁定表(预占、释放、核销) 操作类型五种:`INBOUND`(入库)、`LOCK`(锁定)、`RELEASE`(释放)、`COMMIT`(核销)、`TRANSFER`(转移)。所有数量必须是**整数最小单位**,传小数直接抛错——和"金额用分"是同一个工程哲学。 ### 3.3 采购入库动作的完整配置 看真实配置。采购单审批通过后的入库动作 `inbound_purchase_stock`: ```json { "actionConfig": { "successBehavior": "refreshList", "steps": [ { "stepCode": "foreach_purchase_items", "stepType": "FOREACH", "stepConfig": { "collectionPath": "record.children.pw_purchase_order_item", "itemAlias": "item", "steps": [ { "stepCode": "purchase_item_inbound", "stepType": "DOMAIN_ACTION", "stepConfig": { "actionType": "QUANTITY", "operationType": "INBOUND", "params": { "accountCode": "${record.main.warehouseId}", "itemCode": "${item.materialId}", "quantity": "${item.quantity}", "sourceDetailId": "${item.id}", "remark": "采购审批通过入库" } } } ] } } ] } } ``` 读法很直白:遍历采购单的每一行明细,以仓库为账户、物料为项目,逐行入库。`${...}` 表达式取上下文里的值,`sourceDetailId` 把台账流水和明细行关联起来——**以后任何一笔库存都能反查出是哪张单据的哪一行造成的**。 幂等是强制要求:动作没带幂等键时,引擎用 `来源对象|来源记录|明细行|动作|步骤|操作类型` 拼一个稳定基再哈希,保证同一个审批回调重试多少次都只记一次账。 出库单比入库更进一步,用的是三件套:**提交时 `LOCK`(预占,不影响可用判断)、审批通过 `COMMIT`(按锁定核销)、审批驳回 `RELEASE`(释放锁定)**。调拨则是 `TRANSFER`,从源仓库扣、向目标仓库加,拆成源端/目标端两条流水。 这就是"库存怎么算"的答案:不是一个字段,是账户模型 + 流水 + 幂等 + 锁定语义,而且全部是配置,不是代码。 > 【截图占位 3:动作设计器 — inbound_purchase_stock 动作的可视化配置界面】 --- ## 四、翻车点三:审批联动 最后一个翻车点:流程和业务怎么接上。 Forge 的原则是**不新建审批引擎**——流程继续走 Flowable,低代码只负责"审批结果出来之后干什么"。接法分三步: **第一步,行按钮发起审批。** 采购单列表上有"提交审批"按钮,对应一个 `COMMAND` 动作,里面是一个 `START_FLOW` 步骤:配置流程模型 key,用 `fieldMapping` 把 `record.main.purchaseNo` 等字段映射成流程变量。注意这里用的是平台**通用审批流**(`leave_multi`),不是为采购定制的流程——零定制的又一个佐证。 **第二步,绑定回调动作。** 在 `ai_business_binding` 里配一行 JSON,把"审批通过"接到入库动作: ```sql -- 采购单:审批通过 → 入库 JSON_OBJECT('APPROVED', 'inbound_purchase_stock') -- 出库单:通过 → 核销锁定,驳回 → 释放锁定 JSON_OBJECT('APPROVED', 'commit_outbound_stock', 'REJECTED', 'release_outbound_stock') ``` **第三步,引擎回调执行。** 流程引擎事件通过 `@FlowCallback` 注解进入 `BusinessFlowService`,按审批结果解析出 actionCode,构造执行请求调同一个动作引擎。回调的幂等键格式是 `flowCallback:{processInstanceId}:{result}:{actionCode}`——同一个流程实例的同一个结果,副作用只执行一次。 而且 spec 里有一条硬性要求:**流程回调失败必须可见,不允许"审批成功但库存没动"这种静默失败**。回调执行同样写执行日志,失败了能查、能追溯、能重放。 到这里,三张单据的完整闭环就出来了: ```text 采购单:提交审批(START_FLOW) → 通过(APPROVED) → FOREACH 明细逐行 INBOUND 出库单:提交时 LOCK → 通过 COMMIT / 驳回 RELEASE 调拨单:审批通过 → TRANSFER(源仓库减、目标仓库加,双流水) ``` > 【截图占位 4:仓库详情页 — 库存余额 / 流水 / 锁定 三个数据面板】 --- ## 五、交付清单与诚实的边界 最后交付了什么: - **10 个业务对象**:物料、供应商、供应商报价、仓库(主数据);采购单/出库单/调拨单(单据)+ 各自明细 - **6 个应用入口**:物料、供应商、仓储、采购、出库、调拨管理,从应用中心进入 - **完整的库存语义**:入库、预占、核销、释放、转移,流水可追溯到明细行 - **演示数据**:P.O 42.5 水泥、HRB400 螺纹钢、YJV 电力电缆……拿来就能点(金额字段单位是分,符合工程约定) - **仓库详情**:余额、流水、锁定、关联单据四个数据面板;物料详情含供应商报价和最近流水 也得说清楚没做到什么:**像素级的 UI 还原还没做到**——统计卡片、底部固定操作栏这类视觉细节,是平台 UI 模板层的能力缺口,不是业务逻辑缺口。业务逻辑层面,验收文档的结论是 11 个原型页面全部"低代码等价还原"。 这个边界很重要。低代码平台吹牛的常见方式是把"UI 自由度"和"业务能力"混为一谈。Forge 这轮恰好反过来:**业务能力(主从、台账、审批联动)做到了零代码,UI 模板的丰富度承认还有差距**。前者是地基,后者是装修——地基没打好的平台,装修再漂亮也没用。 --- ## 六、三个值得抄的取舍 复盘一下这轮设计里最有价值的三个判断: 1. **动作引擎做白名单,不做脚本引擎。** 牺牲了"什么都能写"的幻觉,换来权限、事务、幂等、审计全套工程保障。业务逻辑从"代码"变成了"可审查的配置资产"。 2. **数量走台账,不走字段。** 账户 + 流水 + 锁定 + 幂等键,库存从"一个会被改坏的数字"变成"一本可追溯的账"。这个模型不只适用于库存——积分、余额、额度,所有"会变动的数量"都能套。 3. **流程回调复用同一个动作底座。** 手动按钮、定时触发器、审批回调,三个触发点走同一个执行引擎、同一套幂等和日志。没有为审批单独发明一套机制。 --- ## 体验 Forge Admin - 在线演示:http://www.dlforgelab.com:8084/forge/login - 默认账号:admin / 123456 - Gitee:https://gitee.com/ForgeLab/forge-admin 下一篇预告:动作引擎的执行底座再往下拆一层——幂等键怎么设计、并发怎么防、执行日志怎么查,把"业务动作"这件事的工程细节讲透。 --- *你用过哪些低代码平台?主从表和业务逻辑这两关,它们过去了吗?评论区聊聊。*