Appearance
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(发货单)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
orderId | SelectData→app_mall_order | 是 | 落订单主键 id,显示 orderNo;不可编辑 |
orderNo | Text | 否 | 订单号快照 |
shipNo | SerialNumber S-YYYYMMDD-#### | 是 | 发货单号(自动生成) |
expressCompany | Text(maxLength 50) | 否 | 快递公司(可输入文本,实现"可输入可选";录入时填顺丰/圆通等,不强制枚举) |
expressNo | Text(maxLength 64) | 是 | 快递单号 |
shipImages | Image(maxCount 6) | 否 | 快递单图片,对象数组 [{name,url,fileId,size,type}],与 app_mall_review.images 同约定 |
shipTime | DateTime | 是 | 发货时间 |
remark | TextArea | 否 | 发货备注(如"先发 8 箱,剩余 2 箱补发");明细拆分在 shipment_item |
status | —— | —— | 本期未建该字段,发货单状态统一由 order.shipStatus 表达,避免状态冗余 |
系统字段(id/creator/updater/deleted/tenantId/createTime/updateTime)按现有模型模板补全。
3.2 app_mall_shipment_item(发货单明细,必须)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
shipmentId | SelectData→app_mall_shipment | 是 | 所属发货单 |
orderId | SelectData→app_mall_order | 是 | 订单主键(冗余存储,便于聚合查询与 C 端按订单拉列表) |
orderItemId | SelectData→app_mall_order_item | 是 | 定位到具体商品行(10 箱橘子对应哪条明细) |
productId / name / skuId | 快照 | 否 | 商品快照(避免商品改名/下架失真,同 review) |
qty | Number | 是 | 本次发货数量(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.jsShipStatus.ALL_SHIPPED同步)。
主订单 status 主状态:首次发货(order.status==='PAID' 且 newShipStatus!=='UN_SHIPPED')由 shipment_recompute 翻 PAID→SHIPPED(进入待收货);后续追加发货单只更新 shipStatus,不动主状态;确认收货 COMPLETED 不变。
例子(10 箱橘子):
- 建发货单 #1:明细 8 箱,单号 A,图片 →
shipStatus=PART_SHIPPED,status由 PAID 翻SHIPPED(待收货) - 建发货单 #2:明细 2 箱,单号 B,图片 →
shipStatus=ALL_SHIPPED(终态「已发完」)
5. 防超发校验(对齐用户铁律:显式校验,不兜底)
- 原则:
shipment_item写入前,校验该 order_item 已发 qty + 本次 qty ≤ order_item.qty(同时校验订单总量)。 - 实现:
shipment_item的businessRule.callApi(joined+abort)调用shipment_recomputeSCRIPT 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. 改造步骤(已全部完成)
- ✅ 新建
app_mall_shipment.json+app_mall_shipment_item.json(对齐app_mall_review.json模板:系统字段、Image 字段、title、索引)。 - ✅
constants.js的MODEL增加SHIPMENT/SHIPMENT_ITEM;domain/types.js的ShipStatus终态SHIPPED→ALL_SHIPPED(同步ShipStatusText终态"已发完")。 - ✅ 配防超发校验 +
order.shipStatus联动:新建api/shipment_recompute.json(SCRIPT)+shipment_item的两条callApibusinessRule。 - ✅
btn_order_ship/btn_order_ship_batch改createData → app_mall_shipment(带orderId/orderNo字段映射),condition允许status=PAID或SHIPPED。 - ✅ C 端
OrderDetailView.vue:「查看物流」弹窗接入getShipments(orderId),渲染多发货单(公司/单号/图片/明细/时间),case 'logistics'改调openLogistics()。 - ✅
adapters/ltapp/index.js增加order.getShipments(orderId)(复用pageModel+normalizeImageList,并行查 SHIPMENT + SHIPMENT_ITEM)。 - ✅ 删除
api/order_ship.json(不一致 footgun)。 - ✅ 测试:
tests/scenarios/10-order-ship.js重写为分批发货 + 重算 + 超发原子回滚;config.jsShipStatus.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物流弹窗 +ltappadapter 一个方法 +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 什么情况下需要删除发货明细
- 多录 / 录错行 —— 本批根本没发这个商品,或选错商品。
- 该批次撤下 —— 临时缺货/破损,该商品本批不发,留到下一批补。
- 合并批次 —— 两单合一批发,清掉另一张单的明细。
若是整张单都不要了,正确动作是作废整单(9.5),而不是逐行删光。
9.4 红线:以下情况绝不允许删/改明细
- 买家已收货(
status=COMPLETED或shipStatus=RECEIVED)——履约已完成,删明细等于篡改已完成的履约记录,售后与对账无据可依。 - 订单已取消(
CANCELED)。 - 快递已揽收/已有轨迹后修改单号 —— 系统记录与外部凭证对不上。
9.5 三个缺口的落地情况(均已实现,2026-09-08)
| # | 缺口 | 补法(已落地) | 落地位置 |
|---|---|---|---|
| 1 | shipment 无状态字段、无锁定规则 | ① shipment 加 status Radio(SHIPPED/RECEIVED/CANCELED,默认 SHIPPED)+ 视图列;② shipment_recompute 脚本开头加「完结锁定」守卫(订单 COMPLETED/CANCELED 或 shipStatus=RECEIVED 即 throw),置于防超发判定之前 | app_mall_shipment.json 字段+校验规则;shipment_recompute.json 脚本 |
| 2 | shipment_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。