Skip to content

app_mall 物流发货模型设计方案(已落地) ​

目标:补上"发货记录"能力。现状是 app_mall_order 只能在 btn_order_ship 按钮下一次把 status 置为 SHIPPED,无法记录「分批发货 / 快递单号 / 快递单图片」,也不支持按商品行拆分件数(10 箱橘子分 8+2)。本文给出现状诊断、市面对标、最终数据模型与改造结果。所有决策点已拍板、全部代码已落地,本文为落地后定稿。

1. 现状诊断(精确到代码) ​

位置现状问题 / 处置
light-app/file/app-assets/app_mall/model/app_mall_order.json已有 shipStatus(UN_SHIPPED/PART_SHIPPED/ALL_SHIPPED/RECEIVED)和 status 主状态机(PAID→SHIPPED)字段已具备"部分发货"语义,但原没有任何地方写 PART_SHIPPED
api/order_ship.json原 modelOpt 直接 update status=SHIPPED只改主状态、不落任何发货明细——是"不一致 footgun" → 已删除,发货统一走发货单录入
customButtons.btn_order_ship / btn_order_ship_batch改为 createData → app_mall_shipment点击即打开发货单录入表单(零额外前端),支持分批、填单号/图片
front/.../OrderDetailView.vue「查看物流」弹窗原写死 el-empty 占位已接入 getShipments(orderId),渲染多发货单(公司/单号/图片/明细/时间)
adapters/ltapp/index.js order 模块原仅有 remindShip/getShipReminds已增加 order.getShipments(orderId)

根因:缺少"发货单"这一履约层实体。订单状态与发货明细混在 order 上,无法表达"一单多包、按件拆分、每次带单号/图片"。已通过新增 app_mall_shipment + app_mall_shipment_item 解决。

2. 市面商城小程序发货处理方式(对标) ​

维度微信小店 / 有赞 / 微店本商城落地
发货入口商家后台「发货管理」:单笔 / 批量,填快递公司+单号btn_order_ship 改 createData → app_mall_shipment 录入表单(零额外前端)
拆单 / 部分发货支持一单多包、多次发货(缺货先发有货的)支持:每次发货 = 一条发货单
发货凭证快递单号 + 电子面单图片快递单号 + 快递单图片(Image 字段,与 review.images 一致)
按商品拆分支持按 SKU 拆件(一次发某 SKU 的部分数量)支持:发货单明细关联 order_item + 本次件数
物流轨迹自动对接快递 100/官方 API 拉轨迹本期不做(无三方 API),仅记录发货凭证,留扩展位
状态联动子单状态聚合出主单状态(待发/部分发/已发/签收)shipStatus 由发货单层聚合:UN→PART→ALL_SHIPPED

结论:标准做法是 订单(1) → 发货单(N) → 发货单明细(N×M) 三层,主单发货状态由发货单层聚合推算,绝不在订单上直接"写死已发"。

3. 最终数据模型(已建 2 张表) ​

3.1 app_mall_shipment(发货单) ​

字段类型必填说明
orderIdSelectData→app_mall_order是落订单主键 id,显示 orderNo;不可编辑
orderNoText否订单号快照
shipNoSerialNumber S-YYYYMMDD-####是发货单号(自动生成)
expressCompanyText(maxLength 50)否快递公司(可输入文本,实现"可输入可选";录入时填顺丰/圆通等,不强制枚举)
expressNoText(maxLength 64)是快递单号
shipImagesImage(maxCount 6)否快递单图片,对象数组 [{name,url,fileId,size,type}],与 app_mall_review.images 同约定
shipTimeDateTime是发货时间
remarkTextArea否发货备注(如"先发 8 箱,剩余 2 箱补发");明细拆分在 shipment_item
status————本期未建该字段,发货单状态统一由 order.shipStatus 表达,避免状态冗余

系统字段(id/creator/updater/deleted/tenantId/createTime/updateTime)按现有模型模板补全。

3.2 app_mall_shipment_item(发货单明细,必须) ​

字段类型必填说明
shipmentIdSelectData→app_mall_shipment是所属发货单
orderIdSelectData→app_mall_order是订单主键(冗余存储,便于聚合查询与 C 端按订单拉列表)
orderItemIdSelectData→app_mall_order_item是定位到具体商品行(10 箱橘子对应哪条明细)
productId / name / skuId快照否商品快照(避免商品改名/下架失真,同 review)
qtyNumber是本次发货数量(8 / 2)。默认 1

没有这张表,就无法表达"10 箱分 8+2",也无法防超发。

校验 / 联动(落在该模型上):

  • validationRules[0] 明细归属校验(associateQuery 查 app_mall_order_item,条件 id=orderItemId AND orderId=orderId,create+update):拦截把 A 订单商品挂到 B 订单发货。
  • businessRules 两条(create + update 各一),transactionMode=joined + onError=abort,action=callApi → shipment_recompute,fieldMappings 传 orderId/orderItemId/qty/id(均 currentField)。

3.3 物流轨迹表(可选,本期不做) ​

app_mall_logistics_trace(shipmentId, traceTime, traceStatus, traceContent) —— 留扩展位,对接三方 API 时再加;本期「查看物流」直接展示发货单列表(单号/图片/明细/时间)。

4. 状态联动设计(已落地) ​

主订单 shipStatus 由发货单明细聚合推算(单向):

Σ已发 = Σ(app_mall_shipment_item.qty)          // 该订单全部发货单明细(排除本次行的视角由脚本处理)
Σ应发 = Σ(app_mall_order_item.qty)             // 该订单全部下单明细

Σ已发 == 0            → UN_SHIPPED
0 < Σ已发 < Σ应发     → PART_SHIPPED
Σ已发 >= Σ应发        → ALL_SHIPPED   // 终态「已发完」,与订单主状态 status=SHIPPED 区分,避免撞名

命名雷区:status(订单主状态机)和 shipStatus(履约)原来都想用 SHIPPED,会撞名。终态已统一改名为 ALL_SHIPPED(后端 app_mall_order.json 两处 + 前端 types.js ShipStatus.ALL_SHIPPED 同步)。

主订单 status 主状态:首次发货(order.status==='PAID' 且 newShipStatus!=='UN_SHIPPED')由 shipment_recompute 翻 PAID→SHIPPED(进入待收货);后续追加发货单只更新 shipStatus,不动主状态;确认收货 COMPLETED 不变。

例子(10 箱橘子):

  1. 建发货单 #1:明细 8 箱,单号 A,图片 → shipStatus=PART_SHIPPED,status 由 PAID 翻 SHIPPED(待收货)
  2. 建发货单 #2:明细 2 箱,单号 B,图片 → shipStatus=ALL_SHIPPED(终态「已发完」)

5. 防超发校验(对齐用户铁律:显式校验,不兜底) ​

  • 原则:shipment_item 写入前,校验 该 order_item 已发 qty + 本次 qty ≤ order_item.qty(同时校验订单总量)。
  • 实现:shipment_item 的 businessRule.callApi(joined + abort)调用 shipment_recompute SCRIPT API:
    • 聚合 app_mall_shipment_item 已发量(排除当前行 id neq 当前行,无论本行是否已落库都正确);
    • 双层校验:单行商品累计超 itemRequired 抛"超过订单商品应发数量";订单总量超 totalRequired 抛"超过订单应发总数";
    • throw 触发 @Transactional(REQUIRED) 链式回滚,整条发货明细插入被撤销(与库存 increment 原子扣减同范式,根除超卖)。
  • 数量有效性:qty 必须为正整数,失败显式拒绝,不靠 || 默认。

事务正确性实证(读 BusinessRuleServiceImpl / ApiExecutorService / ApiScriptExecutor 三层源码确认):joined 规则随主事务跑,脚本在主事务内可见刚插入的 shipment_item 未提交行;脚本 throw → 主事务回滚 → 本次发货明细不落库。防超发 = atomic。

6. 决策点(已全部拍板) ​

  • A. 是否建 shipment_item 明细表(按件拆分)? → 是,已建。
  • B. 是否做物流轨迹自动同步(三方快递 API)? → 本期不做,仅记录发货凭证,留扩展位。
  • C. 发货操作入口 → 方案①(后台标准表单直录):btn_order_ship / btn_order_ship_batch 改 createData → app_mall_shipment,零额外前端;原独立弹窗方案作废。
  • D. 快递公司字段 → 可输入文本(Text maxLength 50),实现"可输入可选",不强制 Radio 枚举。
  • 触发器数据流 / 聚合表 → 核心用 脚本 API + businessRule.callApi(joined+abort) 实现累计重算与防超发;聚合表作为后续可选增强(非防超发依赖,避免未稳定流水线拦死发货单)。

7. 改造步骤(已全部完成) ​

  1. ✅ 新建 app_mall_shipment.json + app_mall_shipment_item.json(对齐 app_mall_review.json 模板:系统字段、Image 字段、title、索引)。
  2. ✅ constants.js 的 MODEL 增加 SHIPMENT / SHIPMENT_ITEM;domain/types.js 的 ShipStatus 终态 SHIPPED→ALL_SHIPPED(同步 ShipStatusText 终态"已发完")。
  3. ✅ 配防超发校验 + order.shipStatus 联动:新建 api/shipment_recompute.json(SCRIPT)+ shipment_item 的两条 callApi businessRule。
  4. ✅ btn_order_ship / btn_order_ship_batch 改 createData → app_mall_shipment(带 orderId/orderNo 字段映射),condition 允许 status=PAID 或 SHIPPED。
  5. ✅ C 端 OrderDetailView.vue:「查看物流」弹窗接入 getShipments(orderId),渲染多发货单(公司/单号/图片/明细/时间),case 'logistics' 改调 openLogistics()。
  6. ✅ adapters/ltapp/index.js 增加 order.getShipments(orderId)(复用 pageModel + normalizeImageList,并行查 SHIPMENT + SHIPMENT_ITEM)。
  7. ✅ 删除 api/order_ship.json(不一致 footgun)。
  8. ✅ 测试:tests/scenarios/10-order-ship.js 重写为分批发货 + 重算 + 超发原子回滚;config.js ShipStatus.SHIPPED→ALL_SHIPPED 并补 MODEL.SHIPMENT/SHIPMENT_ITEM;04-order-status-machine.js 同步改 ALL_SHIPPED。

8. 影响面小结 ​

  • 后端模型:+2 个 JSON(shipment / shipment_item)+ 1 个 SCRIPT API(shipment_recompute);order.json 改名 + 按钮改 createData;order_ship.json 已删除。
  • 前端:仅 OrderDetailView.vue 物流弹窗 + ltapp adapter 一个方法 + constants.js/types.js 常量,下单/支付/订单列表不受影响。
  • 测试:10-order-ship.js(重写覆盖新流程)、04-order-status-machine.js(shipStatus 终态对齐)、config.js(枚举对齐)。
  • 联调要点:构造「10 箱橘子分 8+2」数据,验证 PART_SHIPPED → ALL_SHIPPED 聚合、首单 PAID→SHIPPED、超发被拒且订单零副作用。

9. 发货单修改与作废语义(业务分析 + 已落地) ​

9.1 可修改窗口(以订单主状态为准) ​

订单状态有发货单?允许的操作
PAID无(首单待录)录入发货单
SHIPPED有改件数 / 增行 / 删行 / 改快递信息,每次保存自动重算
COMPLETED(shipStatus=RECEIVED)有禁止任何改动。需更正 → 作废整单 + 重开
CANCELED可能有禁止改动(订单已终止)

判据来源:确认收货时前端一次性写 status=COMPLETED + shipStatus=RECEIVED(adapters/ltapp/index.js confirmReceipt);订单状态机 COMPLETED.targets=[] 为终态。

9.2 修改发货单的三类场景 ​

类别典型情形正确处置
A 录入纠错单号打错、公司选错、凭证传错、8 箱填成 3 箱直接改覆盖(货未真正出仓,买家未见物流)
B 实际发货变更缺货/破损只能发 5 箱、快递限重拆两单货未出库 → 改本单件数,余量开第二批;货已在途 → 作废重开
C 单据作废录重复、快递取件失败换公司、买家退款拦截作废整单 + 重开,不原地改单号

9.3 什么情况下需要删除发货明细 ​

  1. 多录 / 录错行 —— 本批根本没发这个商品,或选错商品。
  2. 该批次撤下 —— 临时缺货/破损,该商品本批不发,留到下一批补。
  3. 合并批次 —— 两单合一批发,清掉另一张单的明细。

若是整张单都不要了,正确动作是作废整单(9.5),而不是逐行删光。

9.4 红线:以下情况绝不允许删/改明细 ​

  • 买家已收货(status=COMPLETED 或 shipStatus=RECEIVED)——履约已完成,删明细等于篡改已完成的履约记录,售后与对账无据可依。
  • 订单已取消(CANCELED)。
  • 快递已揽收/已有轨迹后修改单号 —— 系统记录与外部凭证对不上。

9.5 三个缺口的落地情况(均已实现,2026-09-08) ​

#缺口补法(已落地)落地位置
1shipment 无状态字段、无锁定规则① shipment 加 status Radio(SHIPPED/RECEIVED/CANCELED,默认 SHIPPED)+ 视图列;② shipment_recompute 脚本开头加「完结锁定」守卫(订单 COMPLETED/CANCELED 或 shipStatus=RECEIVED 即 throw),置于防超发判定之前app_mall_shipment.json 字段+校验规则;shipment_recompute.json 脚本
2shipment_item 缺 delete 规则补 triggerEvent=delete 的 callApi→shipment_recompute(joined+abort),qty 传固定 0,仅触发聚合重算与 shipStatus 回退app_mall_shipment_item.json businessRules
3无「作废」语义、锁不住shipment 的「完结锁定」validationRule(associateQuery 查可编辑态订单,命中才放行;终态查不到→拒绝),triggerEvents=[update,delete];shipment_item 同款 triggerEvents=[create,update,delete]app_mall_shipment.json / app_mall_shipment_item.json validationRules

锁的判定口径(两模型一致):associateQuery 对 app_mall_order 查询 id=本单orderId AND status in (CREATED,PAID,SHIPPED) AND shipStatus ne RECEIVED——

  • 命中(订单处于可编辑态)→ 放行;
  • 查不到(订单已 COMPLETED/CANCELED 或已 RECEIVED)→ 校验不通过 → 拒绝改/删。
  • 注:associateQuery 语义是「命中>0 通过、=0 不通过」(ValidationRuleServiceImpl:157-161 代码为权威,schema 文本写反),故锁条件要写成「查可编辑态」而非「查终态」。

「作废」语义:shipment.status 置 CANCELED 为手动标记作废(默认 SHIPPED,可手动标 RECEIVED/CANCELED)。注意本字段是发货单自身标记,真正的「完结锁定」由 order 状态统一裁决,二者解耦——即使有人误标 shipment=CANCELED 但 order 未完结,锁定仍按 order 走,避免绕过。

⚠️ 存量 NULL 无影响:新增 status 字段后历史行 status=NULL,但锁是 order 驱动而非 shipment 自身状态驱动,故 NULL 不会使历史单绕过锁定;仅列表展示该列为空。无需回填。

✅ 删除重算链路已闭合:删 shipment_item 任意行 → 逻辑删除后 delete businessRule 触发 shipment_recompute(qty=0)→ 聚合自动排除已删行(deleted=0 硬过滤)→ shipStatus 正确回退(如 ALL_SHIPPED→PART_SHIPPED);终态订单的删除被「完结锁定」validationRule 在写前拦下,根本走不到 businessRule。