feat(M09-D2): 完成商品订单与库存占用生命周期
This commit is contained in:
@@ -136,9 +136,9 @@ sh scripts/setup-git-hooks.sh
|
||||
|
||||
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
||||
|
||||
- 当前执行游标:`M09-D2`(TODO);`M09-D1` 已在当前工作树完成商品目录与库存底座收口,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
||||
- 最近工程结果:M09-D1 已建立 9 张商品/库存表、分类/商品/SKU/门店售卖与跨日营业时段、可用/锁定/损耗库存、幂等请求和不可变流水;增量/崩溃重试迁移、数据库级迁移互斥、生产触发器前置、API/RBAC 与 MySQL 8.4.10 往返门禁通过,随本次中文工程提交固化。
|
||||
- 下一工程目标:执行 M09-D2,建立商品订单、订单项快照、支付状态衔接,以及库存锁定、扣减、释放和取消回补闭环。
|
||||
- 当前执行游标:`M09-D3`(TODO);`M09-D2` 已在当前工作树完成商品订单、支付衔接与库存生命周期收口,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
||||
- 最近工程结果:M09-D2 已建立 7 张商品订单/支付/退款表、不可变订单项和事件、顾客与管理 API、门店范围权限,以及下单锁定、并发支付单次扣减、取消/超时释放、退款返库闭环;MySQL 8.4.10 的完整往返和并发回归通过,随本次中文工程提交固化。
|
||||
- 下一工程目标:执行 M09-D3,建立寄存单、分次取出流水、哈希领取凭证、本人/授权员工领取、并发防超领和通知 outbox。
|
||||
|
||||
## 版本递进
|
||||
|
||||
@@ -147,7 +147,7 @@ sh scripts/setup-git-hooks.sh
|
||||
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
|
||||
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
|
||||
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
|
||||
- **V5.7**:增加中文 Git 提交门禁,恢复并收口 M09-D1 商品目录/库存工作树,游标推进至 M09-D2。
|
||||
- **V5.7**:增加中文 Git 提交门禁,持续收口 M09-D1 商品库存与 M09-D2 商品订单库存生命周期,游标推进至 M09-D3。
|
||||
|
||||
## Codex 入口
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 自助棋牌室系统开发总纲(V5.7)
|
||||
|
||||
|
||||
> 文档版本:V5.7(中文 Git 提交门禁 + `fd6be4f` 最新源码校准 + M09-D1 在制恢复 + 持续工程开发版)
|
||||
> 文档版本:V5.7(中文 Git 提交门禁 + `93af128` 最新已推送源码校准 + M09-D2 在制收口 + 持续工程开发版)
|
||||
> 更新日期:2026-08-11(Asia/Shanghai)
|
||||
> 直接前序版本:V5.6;本文完整继承 V5.6 的全部功能、架构、协议、部署、安全、测试、验收和持续开发要求。
|
||||
> 最新已审计提交证据:`fd6be4f9b98bb9213e95973069c402915a8ba6f2`,且上传快照中 `HEAD == origin/main`;其后的 M09-D1 工作区代码尚未提交、尚未通过全部门禁。
|
||||
> 默认执行起点:保护并继续完成当前 M09-D1 在制工作;通过编译、全量测试、真实 MySQL 往返和静态门禁并以中文提交推送后,才能进入 `M09-D2 → M09-D3 → M09-D4 → M09-REGRESSION → M10-A/B/C/D → M10-RC1`。
|
||||
> 最新已审计提交证据:`93af128002c27ea548e9ed7d0c9af7b8f264002a`,且接管时 `HEAD == origin/main`;其后的 M09-D2 工作区代码待随本次中文工程提交固化。
|
||||
> 默认执行起点:保护并继续完成当前 M09-D2 在制工作;通过编译、全量测试、真实 MySQL 往返和静态门禁并以中文提交推送后,进入 `M09-D3 → M09-D4 → M09-REGRESSION → M10-A/B/C/D → M10-RC1`。
|
||||
> 唯一交接目标:Codex 只阅读根目录 `V5.7.md`,即可保护当前工作区,直接继续工程编码、测试、中文提交、推送和远端校验,自动推进至 `CODE_COMPLETE`,外部条件具备后继续至 `PRODUCTION_ACCEPTED`。
|
||||
|
||||
> **V5.7 最高优先级控制规则:** 本文开头“V5.7 增量控制层”和全文最后“第 30 章 V5.7 最终固定游标”共同构成唯一当前执行入口,优先于中间完整继承的 V5.6/V5.5/V5.4 历史标题、旧 HEAD、旧队列、旧示例和旧入口。中间历史正文继续作为完整需求与验收依据,但不得据此把执行游标回退到已经有工程证据的阶段。
|
||||
@@ -15,16 +15,17 @@
|
||||
|
||||
# V5.7 增量控制层
|
||||
|
||||
## V5.7-0 M09-D1 收口覆盖层(2026-08-11 最新真实仓库)
|
||||
## V5.7-0 M09-D2 收口覆盖层(2026-08-11 最新真实仓库)
|
||||
|
||||
本节记录 `fd6be4f + M09-D1 在制工作区` 之后的真实工程进展,优先于下方审计快照中的 M09-D1 `DOING`、缺失断言和旧 HEAD 描述:
|
||||
本节记录 `93af128 + M09-D2 在制工作区` 的真实工程进展,优先于下方审计快照中的 M09-D1 恢复说明、旧 HEAD 和旧游标描述:
|
||||
|
||||
- Git 中文提交门禁已由 `2de796143ff418d681fdb972040b8555a8e058d1` 以 `chore(V5.7): 增加中文提交信息校验门禁` 提交并推送,`HEAD == origin/main`,后续业务提交继续强制中文标题。
|
||||
- M09-D1 当前工作树已完成 9 张商品/库存表、分类/商品/SKU/门店售卖、跨日营业时段、库存策略、可用/锁定/损耗库存、幂等请求和不可变流水;商品/库存 API、RBAC 与租户/门店范围已接线。
|
||||
- 迁移 runner 已按单个 marker/checksum 增量执行 pending 迁移,interrupted up/down DDL 可原地重试,同库 up/verify/down 由专用连接上的数据库命名锁串行化;包含触发器的迁移会在任何触发器 DDL 与建表前检查生产前置,binlog 开启时要求 `log_bin_trust_function_creators=1` 和独立迁移身份。
|
||||
- `assertProductInventoryFoundation` 已实现并调用;M09-D1 静态门禁、后端 build/full test、管理端 build/test 已通过。WSL MySQL 8.4.10 的 `up → verify → down → up → verify` 通过,计数为 179 up、141 verify、171 down,增量/崩溃重试与 cleanup probe 通过。
|
||||
- M09-D1 工程与本文档伴随记录待以 `feat(M09-D1): 完成商品目录与库存流水底座` 中文业务提交取得 SHA;文档不得虚构尚未产生的提交号。
|
||||
- 唯一执行游标已推进到 `M09-D2`,下一目标为商品订单、订单项快照、支付衔接,以及库存锁定、扣减、释放和取消回补。M09-D1 若出现回归只做针对性修复,不得删除重做。
|
||||
- Git 中文提交门禁已由 `2de796143ff418d681fdb972040b8555a8e058d1` 提交并推送;M09-D1 已由 `93af128002c27ea548e9ed7d0c9af7b8f264002a` 以 `feat(M09-D1): 完成商品目录与库存流水底座` 中文业务提交闭环,接管时 `HEAD == origin/main`。
|
||||
- M09-D2 当前工作树已完成 7 张商品订单/支付/退款表,商品订单与支付单分离,订单项固化商品、SKU、规格、价格、数量和金额快照,订单/退款事件由数据库触发器保持不可变。
|
||||
- 顾客下单按服务端目录和门店价格复算并锁定库存;支付回调在死锁/锁等待时有限事务重试且只扣一次,取消/超时释放,退款按履约状态决定返库。配送订单校验本人有效房间订单,自助订单不绑定房间。
|
||||
- 顾客与管理 API、`goods.order.read/manage`、租户/门店范围和测试适配器生产隔离已接线;M09-D2 静态门禁、后端 build/full test 已通过。
|
||||
- WSL MySQL 8.4.10 的 `up → verify → down → up → verify` 已通过,计数为 201 up、150 verify、187 down;并发支付单次扣减、取消/超时释放、退款返库、不可变事件和 M09-D1 跨阶段崩溃重试均有真实数据库证据。
|
||||
- M09-D2 工程与本文档伴随记录待以 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文业务提交取得 SHA;文档不得虚构尚未产生的提交号。
|
||||
- 唯一执行游标已推进到 `M09-D3`,下一目标为寄存单、分次取出、哈希领取凭证、并发防超领和通知 outbox。M09-D1/D2 若出现回归只做针对性修复,不得删除重做。
|
||||
|
||||
## V5.7-1 本次升级的唯一事实边界
|
||||
|
||||
@@ -7069,23 +7070,29 @@ TARGET=CODE_COMPLETE_THEN_RELEASE_CANDIDATE_THEN_PRODUCTION_ACCEPTED
|
||||
execution_cursor_v57:
|
||||
document_version: V5.7
|
||||
audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f
|
||||
evidence_head: 2de796143ff418d681fdb972040b8555a8e058d1
|
||||
evidence_origin_main: 2de796143ff418d681fdb972040b8555a8e058d1
|
||||
evidence_head: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
||||
evidence_origin_main: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
||||
base_branch: main
|
||||
remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git
|
||||
current_stage: M09-D2
|
||||
current_stage_status: TODO_AFTER_M09_D1_WORKTREE_CLOSURE
|
||||
last_completed_functional_stage: M09-D1
|
||||
last_engineering_commit: 1f1071501f4954ad0440d79c2a38e7210393e928
|
||||
current_stage: M09-D3
|
||||
current_stage_status: TODO_AFTER_M09_D2_WORKTREE_CLOSURE
|
||||
last_completed_functional_stage: M09-D2
|
||||
last_engineering_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
||||
last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1
|
||||
m09_d1_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
|
||||
m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
||||
m09_d1_static_gate: PASS
|
||||
m09_d1_backend_build_and_full_test: PASS
|
||||
m09_d1_admin_build_and_test: PASS
|
||||
m09_d1_mysql_84_roundtrip: PASS_179_UP_141_VERIFY_171_DOWN
|
||||
m09_d1_interrupted_up_down_retry: PASS
|
||||
m09_d1_cleanup_probe: PASS
|
||||
default_next_stage: M09-D3
|
||||
m09_d2_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
|
||||
m09_d2_static_gate: PASS
|
||||
m09_d2_backend_build_and_full_test: PASS
|
||||
m09_d2_mysql_84_roundtrip: PASS_201_UP_150_VERIFY_187_DOWN
|
||||
m09_d2_concurrent_payment_deduct: PASS
|
||||
m09_d2_cancel_timeout_refund_inventory: PASS
|
||||
default_next_stage: M09-D4
|
||||
actual_head_policy: REAL_REPOSITORY_EVIDENCE_WINS
|
||||
worktree_protected: true
|
||||
unresolved_deletions_policy: EXCLUDE_FROM_STAGE_COMMIT_UNTIL_USER_CONFIRMS
|
||||
@@ -7099,13 +7106,12 @@ execution_cursor_v57:
|
||||
auto_continue_until_code_complete: true
|
||||
```
|
||||
|
||||
真实 `D:\qipai` 若有 `2de7961` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-D1 的推进依据是商品/库存工程、迁移、测试与真实 MySQL 共同证据,不是 docs-only 状态变更;若伴随业务提交尚未完成,先保护并提交当前工作树,再继续 M09-D2。
|
||||
真实 `D:\qipai` 若有 `93af128` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-D2 的推进依据是商品订单、支付库存工程、迁移、测试与真实 MySQL 共同证据,不是 docs-only 状态变更;若伴随业务提交尚未完成,先保护并提交当前工作树,再继续 M09-D3。
|
||||
|
||||
## 30.2 最终唯一队列
|
||||
|
||||
```text
|
||||
M09-D2
|
||||
→ M09-D3
|
||||
M09-D3
|
||||
→ M09-D4
|
||||
→ M09-REGRESSION
|
||||
→ M10-A
|
||||
@@ -7118,13 +7124,13 @@ M09-D2
|
||||
→ PRODUCTION_ACCEPTED
|
||||
```
|
||||
|
||||
Git 中文门禁和 M09-D1 已有真实工程与测试证据,当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M09-D2。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
|
||||
Git 中文门禁和 M09-D1 已由真实工程提交闭环;M09-D2 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M09-D3。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
|
||||
|
||||
M09-D1 的唯一允许推进条件:
|
||||
M09-D2 的唯一允许推进条件:
|
||||
|
||||
```text
|
||||
M09_D1_WORKTREE_PROTECTED=PASS
|
||||
M09_D1_STATIC_GATE=PASS
|
||||
M09_D2_WORKTREE_PROTECTED=PASS
|
||||
M09_D2_STATIC_GATE=PASS
|
||||
BACKEND_BUILD=PASS
|
||||
BACKEND_FULL_TEST=PASS
|
||||
MYSQL_84_ROUNDTRIP=PASS
|
||||
@@ -7135,7 +7141,7 @@ PUSH_VERIFIED=PASS
|
||||
REMOTE_COMMIT_SUBJECT_CN=PASS
|
||||
```
|
||||
|
||||
任一项未通过时保持 `CURRENT_STAGE=M09-D1`,修复后重跑;不能先更新为 DONE 或进入 M09-D2。
|
||||
任一项未通过时保持 `CURRENT_STAGE=M09-D2`,修复后重跑;不能先更新为 DONE 或进入 M09-D3。
|
||||
|
||||
## 30.3 每次提交和推送的最终语言门禁
|
||||
|
||||
@@ -7196,4 +7202,4 @@ USER_FACING_LANGUAGE=ZH_CN
|
||||
请阅读 V5.7.md,按当前进度继续开发。
|
||||
```
|
||||
|
||||
Codex 读取后直接核验真实仓库,保护 M09-D1 已完成工作树与未决删除,完成 `feat(M09-D1): 完成商品目录与库存流水底座` 中文 commit、push 和远端校验,然后从 M09-D2 建立商品订单与库存占用生命周期并沿唯一队列持续推进。禁止先生成替代计划文档,禁止只改 Markdown,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。
|
||||
Codex 读取后直接核验真实仓库,保护已完成阶段与未决删除;若 M09-D2 工作树尚未提交,完成 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文 commit、push 和远端校验,然后从 M09-D3 建立寄存/取出安全闭环并沿唯一队列持续推进。禁止先生成替代计划文档,禁止只改 Markdown,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
|
||||
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
|
||||
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
|
||||
import {
|
||||
registerProductOrderRoutes,
|
||||
type ProductOrderRouteOptions
|
||||
} from './routes/product-orders.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -94,6 +98,7 @@ export interface BuildAppOptions {
|
||||
adminAuth?: AdminAuthRouteOptions;
|
||||
products?: ProductRouteOptions;
|
||||
inventory?: InventoryRouteOptions;
|
||||
productOrders?: ProductOrderRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -216,6 +221,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.inventory) {
|
||||
await registerInventoryRoutes(app, options.inventory);
|
||||
}
|
||||
if (options.productOrders) {
|
||||
await registerProductOrderRoutes(app, options.productOrders);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -140,20 +140,23 @@ export class AuthRepository {
|
||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p ON
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -38,7 +38,8 @@ export class RbacRepository {
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
'product.catalog.read', 'inventory.read',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
OR (r.code = 'CLEANER'
|
||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
@@ -48,6 +49,7 @@ export class RbacRepository {
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
@@ -55,6 +57,7 @@ export class RbacRepository {
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
|
||||
@@ -71,7 +71,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -109,9 +110,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||
@@ -366,7 +369,8 @@ async function executeMigrationPlanUnlocked(
|
||||
1, 2, 3, 1,
|
||||
3, 7, 3, 1,
|
||||
2, 8, 4, 1,
|
||||
9, 63, 18, 28, 15, 2, 4, 1, 1
|
||||
9, 63, 18, 28, 15, 2, 4, 1, 1,
|
||||
7, 54, 22, 18, 34, 6, 2, 1, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -226,6 +226,7 @@ interface DeltaState {
|
||||
lockedDelta: number;
|
||||
lossDelta: number;
|
||||
reservationDelta: number;
|
||||
saleDelta: number;
|
||||
next: StockState;
|
||||
}
|
||||
|
||||
@@ -416,7 +417,8 @@ export class InventoryService {
|
||||
lowStockThreshold: input.policyType === 'UNLIMITED' ? 0 : threshold
|
||||
};
|
||||
return this.persistMutation(connection, stock, identity, {
|
||||
availableDelta: 0, lockedDelta: 0, lossDelta: 0, reservationDelta: 0, next
|
||||
availableDelta: 0, lockedDelta: 0, lossDelta: 0,
|
||||
reservationDelta: 0, saleDelta: 0, next
|
||||
}, auditFromActor(actor));
|
||||
});
|
||||
}
|
||||
@@ -499,11 +501,22 @@ export class InventoryService {
|
||||
|
||||
async deductMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
|
||||
return this.batchMutation(input, 'DEDUCT', (stock, quantity) => {
|
||||
if (stock.policyType === 'UNLIMITED') return delta(stock, 0, 0, 0, -quantity);
|
||||
if (stock.policyType === 'UNLIMITED') {
|
||||
return delta(stock, 0, 0, 0, -quantity, quantity);
|
||||
}
|
||||
if (number(stock.lockedQuantity) < quantity) {
|
||||
throw new InventoryError('INVENTORY_INSUFFICIENT_LOCKED');
|
||||
}
|
||||
return delta(stock, 0, -quantity, 0, -quantity);
|
||||
return delta(stock, 0, -quantity, 0, -quantity, quantity);
|
||||
}, connection);
|
||||
}
|
||||
|
||||
async returnMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
|
||||
return this.batchMutation(input, 'RETURN', (stock, quantity) => {
|
||||
if (stock.policyType === 'UNLIMITED') {
|
||||
return delta(stock, 0, 0, 0, 0, -quantity);
|
||||
}
|
||||
return delta(stock, quantity, 0, 0, 0, -quantity);
|
||||
}, connection);
|
||||
}
|
||||
|
||||
@@ -554,7 +567,7 @@ export class InventoryService {
|
||||
|
||||
private async batchMutation(
|
||||
input: InventoryBatchMutationInput,
|
||||
operation: 'LOCK' | 'RELEASE' | 'DEDUCT',
|
||||
operation: 'LOCK' | 'RELEASE' | 'DEDUCT' | 'RETURN',
|
||||
calculate: (stock: InventoryRow, quantity: number) => DeltaState,
|
||||
externalConnection?: PoolConnection
|
||||
) {
|
||||
@@ -605,11 +618,16 @@ export class InventoryService {
|
||||
}
|
||||
return { items: duplicates as InventoryMutationResult[], idempotent: true };
|
||||
}
|
||||
if (operation !== 'LOCK') {
|
||||
if (operation === 'RELEASE' || operation === 'DEDUCT') {
|
||||
await this.assertReservationBalances(
|
||||
connection, normalized.tenantId, normalized.storeId,
|
||||
normalized.businessType, normalized.businessId, stocks
|
||||
);
|
||||
} else if (operation === 'RETURN') {
|
||||
await this.assertReturnBalances(
|
||||
connection, normalized.tenantId, normalized.storeId,
|
||||
normalized.businessType, normalized.businessId, stocks
|
||||
);
|
||||
}
|
||||
const changes = stocks.map(({ stock, quantity }) => ({
|
||||
stock, change: calculate(stock, quantity)
|
||||
@@ -762,6 +780,48 @@ export class InventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertReturnBalances(
|
||||
connection: PoolConnection,
|
||||
tenantId: string,
|
||||
storeId: string,
|
||||
businessType: string,
|
||||
businessId: string,
|
||||
stocks: Array<{ stock: InventoryRow; quantity: number }>
|
||||
) {
|
||||
const placeholders = stocks.map(() => '?').join(', ');
|
||||
const [rows] = await connection.execute<ReservationRow[]>(
|
||||
`SELECT inventory_id AS inventoryId,
|
||||
COALESCE(SUM(COALESCE(
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.inventorySaleDelta'))
|
||||
AS SIGNED),
|
||||
CASE
|
||||
WHEN operation = 'DEDUCT' THEN -COALESCE(
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata,
|
||||
'$.inventoryReservationDelta')) AS SIGNED), locked_delta)
|
||||
WHEN operation = 'RETURN' THEN available_delta
|
||||
ELSE 0
|
||||
END
|
||||
)), 0) AS reservedQuantity
|
||||
FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND store_id = ?
|
||||
AND business_type = ? AND business_id = ?
|
||||
AND operation IN ('DEDUCT', 'RETURN')
|
||||
AND inventory_id IN (${placeholders})
|
||||
GROUP BY inventory_id
|
||||
FOR SHARE`,
|
||||
[tenantId, storeId, businessType, businessId,
|
||||
...stocks.map(({ stock }) => stock.id)]
|
||||
);
|
||||
const balances = new Map(
|
||||
rows.map((row) => [String(row.inventoryId), number(row.reservedQuantity)])
|
||||
);
|
||||
for (const { stock, quantity } of stocks) {
|
||||
if ((balances.get(stock.id) ?? 0) < quantity) {
|
||||
throw new InventoryError('INVENTORY_INSUFFICIENT_DEDUCTED');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertNoActiveReservations(
|
||||
connection: PoolConnection,
|
||||
stock: InventoryRow
|
||||
@@ -883,6 +943,7 @@ export class InventoryService {
|
||||
...identity.metadata,
|
||||
idempotencyFingerprint: identity.fingerprint,
|
||||
inventoryReservationDelta: change.reservationDelta,
|
||||
inventorySaleDelta: change.saleDelta,
|
||||
resultPolicyType: change.next.policyType,
|
||||
resultLowStockThreshold: change.next.lowStockThreshold
|
||||
};
|
||||
@@ -943,6 +1004,7 @@ export class InventoryService {
|
||||
lockedDelta: change.lockedDelta,
|
||||
lossDelta: change.lossDelta,
|
||||
reservationDelta: change.reservationDelta,
|
||||
saleDelta: change.saleDelta,
|
||||
versionAfter: number(stock.version) + 1
|
||||
})]
|
||||
);
|
||||
@@ -1088,7 +1150,8 @@ function delta(
|
||||
availableDelta: number,
|
||||
lockedDelta: number,
|
||||
lossDelta: number,
|
||||
reservationDelta = 0
|
||||
reservationDelta = 0,
|
||||
saleDelta = 0
|
||||
): DeltaState {
|
||||
const next = {
|
||||
policyType: stock.policyType,
|
||||
@@ -1098,7 +1161,7 @@ function delta(
|
||||
lowStockThreshold: number(stock.lowStockThreshold)
|
||||
};
|
||||
validateState(next);
|
||||
return { availableDelta, lockedDelta, lossDelta, reservationDelta, next };
|
||||
return { availableDelta, lockedDelta, lossDelta, reservationDelta, saleDelta, next };
|
||||
}
|
||||
|
||||
function validateState(state: StockState) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import {
|
||||
ProductOrderError,
|
||||
productOrderManagementActions,
|
||||
productOrderStatuses,
|
||||
type ProductOrderActor,
|
||||
type ProductOrderService
|
||||
} from '../products/product-order-service.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
|
||||
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
|
||||
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
|
||||
const orderParams = z.object({ orderId: id }).strict();
|
||||
const paymentParams = z.object({ paymentId: id }).strict();
|
||||
const refundParams = z.object({ refundId: id }).strict();
|
||||
const createSchema = z.object({
|
||||
storeId: id,
|
||||
requestId,
|
||||
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE']),
|
||||
roomOrderId: id.nullable().optional(),
|
||||
note: z.string().trim().max(512).default(''),
|
||||
items: z.array(z.object({
|
||||
skuId: id,
|
||||
quantity: z.number().int().min(1).max(1_000_000),
|
||||
note: z.string().trim().max(256).default('')
|
||||
}).strict()).min(1).max(100)
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.fulfillmentMode === 'DELIVERY' && !value.roomOrderId) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['roomOrderId'],
|
||||
message: 'roomOrderId is required for delivery orders'
|
||||
});
|
||||
}
|
||||
if (value.fulfillmentMode === 'SELF_SERVICE' && value.roomOrderId) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['roomOrderId'],
|
||||
message: 'roomOrderId is not allowed for self-service orders'
|
||||
});
|
||||
}
|
||||
});
|
||||
const customerListSchema = z.object({
|
||||
storeId: id.optional(), status: z.enum(productOrderStatuses).optional(), page, pageSize
|
||||
}).strict();
|
||||
const managementListSchema = z.object({
|
||||
storeId: id, status: z.enum(productOrderStatuses).optional(), page, pageSize
|
||||
}).strict();
|
||||
const cancelSchema = z.object({
|
||||
requestId, reason: z.string().trim().min(1).max(512)
|
||||
}).strict();
|
||||
const actionSchema = z.object({
|
||||
requestId,
|
||||
action: z.enum(productOrderManagementActions),
|
||||
reason: z.string().trim().max(512).default('')
|
||||
}).strict();
|
||||
const testPaymentSchema = z.object({ requestId, provider: z.literal('TEST') }).strict();
|
||||
const testCallbackSchema = z.object({
|
||||
callbackId: requestId,
|
||||
amountCents: z.number().int().min(1).max(100_000_000_000_000)
|
||||
}).strict();
|
||||
|
||||
export interface ProductOrderRouteOptions {
|
||||
service: Pick<ProductOrderService,
|
||||
'create' | 'listForCustomer' | 'getForCustomer' | 'cancelForCustomer'
|
||||
| 'listForManagement' | 'getForManagement' | 'managementAction'
|
||||
| 'createPaymentForCustomer' | 'completeTestPaymentForCustomer'
|
||||
| 'completeTestRefundForManagement'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
testAdapterEnabled?: boolean;
|
||||
}
|
||||
|
||||
export async function registerProductOrderRoutes(
|
||||
app: FastifyInstance,
|
||||
options: ProductOrderRouteOptions
|
||||
) {
|
||||
app.post('/app-api/product-orders', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const body = createSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.service.create(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/product-orders', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const query = customerListSchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listForCustomer(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/product-orders/:orderId', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getForCustomer(actor, params.data.orderId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/product-orders/:orderId/cancel', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
const body = cancelSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.cancelForCustomer(
|
||||
actor, params.data.orderId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
if (options.testAdapterEnabled) {
|
||||
app.post('/app-api/product-orders/:orderId/payments', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
const body = testPaymentSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.service.createPaymentForCustomer(
|
||||
actor, params.data.orderId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/product-payments/:paymentId/test-complete', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = paymentParams.safeParse(request.params);
|
||||
const body = testCallbackSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.completeTestPaymentForCustomer(
|
||||
actor, params.data.paymentId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
for (const prefix of ['/admin-api', '/app-api/management']) {
|
||||
app.get(`${prefix}/product-orders`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, false);
|
||||
const query = managementListSchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listForManagement(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get(`${prefix}/product-orders/:orderId`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getForManagement(actor, params.data.orderId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post(`${prefix}/product-orders/:orderId/actions`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
const body = actionSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.managementAction(actor, params.data.orderId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
if (options.testAdapterEnabled) {
|
||||
app.post(`${prefix}/product-refunds/:refundId/test-complete`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const params = refundParams.safeParse(request.params);
|
||||
const body = testCallbackSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.completeTestRefundForManagement(
|
||||
actor, params.data.refundId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: ProductOrderRouteOptions,
|
||||
management: boolean,
|
||||
write = false
|
||||
): Promise<ProductOrderActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
if (management) {
|
||||
const manager = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const capability = write ? 'goods.order.manage' : 'goods.order.read';
|
||||
if (!manager && !access.capabilities.includes(capability)
|
||||
&& !(write && access.capabilities.includes('goods.order.manage'))) {
|
||||
reply.status(403).send({
|
||||
code: 'PRODUCT_ORDER_OPERATION_FORBIDDEN',
|
||||
message: 'Product order permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
source: management ? 'MANAGEMENT' : 'CUSTOMER',
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProductOrderError)) throw error;
|
||||
const status = error.code === 'PRODUCT_ORDER_NOT_FOUND'
|
||||
|| error.code === 'PRODUCT_PAYMENT_NOT_FOUND'
|
||||
|| error.code === 'PRODUCT_REFUND_NOT_FOUND' ? 404
|
||||
: error.code.includes('FORBIDDEN') ? 403
|
||||
: error.code.includes('CONFLICT') || error.code.includes('NOT_ALLOWED')
|
||||
|| error.code.includes('IN_PROGRESS') ? 409
|
||||
: 400;
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The requested product order operation is not available.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'PRODUCT_ORDER_INPUT_INVALID',
|
||||
message: 'The product order input is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { SystemOperationsRepository } from './operations/system-operations-repos
|
||||
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
||||
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
|
||||
import { InventoryService } from './inventory/inventory-service.js';
|
||||
import { ProductOrderService } from './products/product-order-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -58,6 +59,7 @@ const marketingBenefits = new MarketingBenefitService(pool);
|
||||
const cleaningTaskRepository = new CleaningTaskRepository(pool);
|
||||
const productCatalogRepository = new ProductCatalogRepository(pool);
|
||||
const inventoryService = new InventoryService(pool);
|
||||
const productOrderService = new ProductOrderService(pool, inventoryService);
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
@@ -257,6 +259,13 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
productOrders: {
|
||||
service: productOrderService,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
testAdapterEnabled: config.payment.testAdapterEnabled
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -167,6 +167,26 @@ function execute(sql, params) {
|
||||
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
|
||||
return [row ? [{ ...row }] : [], []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity') && sql.includes('inventorySaleDelta')) {
|
||||
const inventoryIds = new Set(params.slice(4).map(String));
|
||||
const balances = new Map();
|
||||
for (const item of state.ledgers) {
|
||||
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|
||||
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|
||||
|| !inventoryIds.has(item.inventoryId)
|
||||
|| !['DEDUCT', 'RETURN'].includes(item.operation)) continue;
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
const fallback = item.operation === 'DEDUCT'
|
||||
? -Number(metadata.inventoryReservationDelta ?? item.lockedDelta)
|
||||
: Number(item.availableDelta);
|
||||
balances.set(item.inventoryId,
|
||||
(balances.get(item.inventoryId) ?? 0)
|
||||
+ Number(metadata.inventorySaleDelta ?? fallback));
|
||||
}
|
||||
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
|
||||
inventoryId, reservedQuantity
|
||||
})), []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
|
||||
const inventoryIds = new Set(params.slice(4).map(String));
|
||||
const balances = new Map();
|
||||
@@ -376,6 +396,51 @@ assert.deepEqual(
|
||||
[8, 0]
|
||||
);
|
||||
|
||||
const returned = await service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 1 }]
|
||||
});
|
||||
assert.equal(returned.idempotent, false);
|
||||
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
|
||||
const returnedReplay = await service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 1 }]
|
||||
});
|
||||
assert.equal(returnedReplay.idempotent, true);
|
||||
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 2 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'foreign-order-return', businessId: 'order-foreign',
|
||||
reason: '跨订单退款不得回补', items: [{ skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-too-many', reason: '超额退款不得回补',
|
||||
items: [{ skuId: '5', quantity: 2 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-case', businessId: 'ORDER-500',
|
||||
reason: '业务号大小写敏感', items: [{ skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
|
||||
const beforeFailedBatch = structuredClone(state.stocks);
|
||||
await assert.rejects(
|
||||
() => service.lockMany({
|
||||
@@ -443,4 +508,4 @@ assert.ok(history.items.length >= 7);
|
||||
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
|
||||
assert.equal(state.audits.length, state.ledgers.length);
|
||||
|
||||
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
|
||||
console.log('PASS: M09-D1 inventory policy, batch reservation/sale return lifecycle, idempotency, immutable ledger, scope and audit work.');
|
||||
|
||||
@@ -129,6 +129,15 @@ const productInventoryDownSql = read(
|
||||
const productInventoryVerifySql = read(
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||
);
|
||||
const productOrderPaymentUpSql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql'
|
||||
);
|
||||
const productOrderPaymentDownSql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql'
|
||||
);
|
||||
const productOrderPaymentVerifySql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -611,4 +620,68 @@ assert.match(productInventoryVerifySql, /fully_granted_product_roles/);
|
||||
assert.match(productInventoryVerifySql, /'uq_qipai_product_inventory_ledger_version'/);
|
||||
assert.match(productInventoryVerifySql, /'2026081107'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D1 migration contracts are present.');
|
||||
for (const table of [
|
||||
'qipai_product_orders',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_refund_events'
|
||||
]) {
|
||||
assert.match(productOrderPaymentUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||
assert.match(productOrderPaymentDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||
assert.match(productOrderPaymentVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.doesNotMatch(productOrderPaymentUpSql, /\bDECIMAL\b/i);
|
||||
for (const state of [
|
||||
'PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING',
|
||||
'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED',
|
||||
'REFUNDING', 'REFUNDED', 'REFUND_FAILED'
|
||||
]) assert.match(productOrderPaymentUpSql, new RegExp(`'${state}'`));
|
||||
assert.match(productOrderPaymentUpSql, /fulfillment_mode = 'DELIVERY' AND room_order_id IS NOT NULL/);
|
||||
assert.match(productOrderPaymentUpSql, /fulfillment_mode = 'SELF_SERVICE' AND room_order_id IS NULL/);
|
||||
assert.match(productOrderPaymentUpSql, /subtotal_cents = unit_price_cents \* quantity/);
|
||||
for (const snapshotColumn of [
|
||||
'product_code', 'product_name', 'sku_code', 'sku_name',
|
||||
'unit_name', 'attributes_snapshot', 'unit_price_cents', 'quantity', 'subtotal_cents'
|
||||
]) assert.match(productOrderPaymentUpSql, new RegExp(`\\b${snapshotColumn}\\b`));
|
||||
assert.match(productOrderPaymentUpSql, /order_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /payment_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /refund_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /callback_id VARCHAR\(128\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_business_id VARCHAR\(128\) COLLATE utf8mb4_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /request_fingerprint CHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_order_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_provider_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_callback_provider/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_provider_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_active_order/);
|
||||
assert.match(productOrderPaymentUpSql, /status IN \('PENDING', 'PROCESSING'\) THEN order_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_active_payment/);
|
||||
assert.match(productOrderPaymentUpSql, /status IN \('PENDING', 'PROCESSING'\) THEN payment_id/);
|
||||
assert.match(productOrderPaymentUpSql, /fk_qipai_product_order_inventory_request/);
|
||||
assert.match(productOrderPaymentUpSql, /fk_qipai_product_refund_inventory_request/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_disposition = 'RESTOCK'/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_disposition = 'NO_RESTOCK'/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_status IN \('LOCKED', 'DEDUCTED', 'RELEASED', 'RETURNED'\)/);
|
||||
assert.match(productOrderPaymentUpSql, /'COMPENSATION_REQUIRED', 'REFUNDED'/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_items_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_items_no_delete/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_events_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_events_no_delete/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_refund_events_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_refund_events_no_delete/);
|
||||
for (const permission of ['goods.order.read', 'goods.order.manage']) {
|
||||
const pattern = new RegExp(permission.replace('.', '\\.'));
|
||||
assert.match(productOrderPaymentUpSql, pattern);
|
||||
assert.match(productOrderPaymentDownSql, pattern);
|
||||
assert.match(productOrderPaymentVerifySql, pattern);
|
||||
}
|
||||
assert.match(productOrderPaymentUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
|
||||
assert.match(productOrderPaymentVerifySql, /fully_granted_goods_order_roles/);
|
||||
assert.match(productOrderPaymentVerifySql, /'2026081108'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D2 migration contracts are present.');
|
||||
|
||||
@@ -46,7 +46,8 @@ assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/);
|
||||
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -59,11 +60,20 @@ assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql/);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081107_m09d1_product_inventory_foundation\.verify\.sql$/
|
||||
/2026081107_m09d1_product_inventory_foundation\.verify\.sql/
|
||||
);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081108_m09d2_product_order_payment_inventory\.verify\.sql$/
|
||||
);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||
< downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
);
|
||||
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
|
||||
@@ -54,6 +54,9 @@ import {
|
||||
import {
|
||||
InventoryError, InventoryService
|
||||
} from '../dist/inventory/inventory-service.js';
|
||||
import {
|
||||
ProductOrderError, ProductOrderService
|
||||
} from '../dist/products/product-order-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -107,6 +110,13 @@ const expectedTables = [
|
||||
'qipai_product_inventory',
|
||||
'qipai_product_inventory_ledger',
|
||||
'qipai_product_inventory_requests',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_orders',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refund_events',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_skus',
|
||||
'qipai_product_store_hours',
|
||||
'qipai_product_store_listings',
|
||||
@@ -160,11 +170,28 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
repoRoot,
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation'
|
||||
);
|
||||
const [upSql, downSql] = await Promise.all([
|
||||
const productOrderMigrationBase = resolve(
|
||||
repoRoot,
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql] = await Promise.all([
|
||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8')
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8')
|
||||
]);
|
||||
const upStatements = splitSqlStatements(upSql);
|
||||
let productOrderDownAttempt = 0;
|
||||
const removeProductOrderDependents = async () => {
|
||||
productOrderDownAttempt += 1;
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${productOrderMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
checksum: `m09d2-before-m09d1-retry-${productOrderDownAttempt}`,
|
||||
statements: splitSqlStatements(productOrderDownSql)
|
||||
});
|
||||
};
|
||||
|
||||
await removeProductOrderDependents();
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${migrationBase}.down.sql`,
|
||||
@@ -175,6 +202,7 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
|
||||
await removeProductOrderDependents();
|
||||
const interruptedDownStatements = splitSqlStatements(downSql).slice(0, -1);
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
@@ -236,14 +264,14 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
||||
'2026081006', '2026081107']
|
||||
'2026081006', '2026081107', '2026081108']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1931,7 +1959,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081107');
|
||||
assert.equal(overview.latestMigration.version, '2026081108');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -3997,6 +4025,292 @@ async function assertProductInventoryFoundation(pool, context) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertProductOrderPaymentInventory(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur
|
||||
ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r
|
||||
ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
|
||||
AND u.deleted_at IS NULL ORDER BY u.id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [customerRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur
|
||||
ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r
|
||||
ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'CUSTOMER'
|
||||
AND u.deleted_at IS NULL ORDER BY u.id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [storeRows] = await pool.query(
|
||||
`SELECT id FROM qipai_stores
|
||||
WHERE tenant_id = ? AND name = 'M03A Store' AND deleted_at IS NULL
|
||||
ORDER BY id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [categoryRows] = await pool.query(
|
||||
`SELECT id FROM qipai_product_categories
|
||||
WHERE tenant_id = ? AND store_id = ? AND category_code = 'M09D1-DRINKS'
|
||||
AND status = 'ACTIVE' AND deleted_at IS NULL ORDER BY id LIMIT 1`,
|
||||
[context.tenantId, storeRows[0]?.id]
|
||||
);
|
||||
assert.ok(adminRows[0] && customerRows[0] && storeRows[0] && categoryRows[0],
|
||||
'M09-D2 requires the M09-D1 tenant, customer, store and category fixtures.');
|
||||
const adminId = String(adminRows[0].id);
|
||||
const customerId = String(customerRows[0].id);
|
||||
const storeId = String(storeRows[0].id);
|
||||
const categoryId = String(categoryRows[0].id);
|
||||
const rbac = new RbacRepository(pool);
|
||||
const adminAccess = await rbac.getAccessProfile(context.tenantId, adminId);
|
||||
for (const capability of ['goods.order.read', 'goods.order.manage']) {
|
||||
assert.ok(adminAccess.capabilities.includes(capability),
|
||||
`missing M09-D2 capability ${capability}`);
|
||||
}
|
||||
const adminActor = {
|
||||
tenantId: context.tenantId, userId: adminId, access: adminAccess,
|
||||
source: 'MANAGEMENT', traceId: 'm09d2-live-admin', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D2 live MySQL admin test'
|
||||
};
|
||||
const customerActor = {
|
||||
tenantId: context.tenantId, userId: customerId,
|
||||
access: await rbac.getAccessProfile(context.tenantId, customerId),
|
||||
source: 'CUSTOMER', traceId: 'm09d2-live-customer', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D2 live MySQL customer test'
|
||||
};
|
||||
const catalog = new ProductCatalogRepository(pool);
|
||||
const inventory = new InventoryService(pool);
|
||||
const service = new ProductOrderService(pool, inventory, {
|
||||
now: () => new Date('2026-08-10T15:00:00.000Z'),
|
||||
paymentHoldMinutes: 15
|
||||
});
|
||||
|
||||
const product = await catalog.createProduct(adminActor, {
|
||||
productCode: 'M09D2-ORDER-DRINK', name: 'M09D2 Order Drink', unitName: 'bottle',
|
||||
description: 'M09-D2 transactional product', coverUrl: '', images: [],
|
||||
deliveryEnabled: true, storageEnabled: true, status: 'ACTIVE', sortOrder: 2
|
||||
});
|
||||
const sku = await catalog.createSku(adminActor, product.productId, {
|
||||
skuCode: 'M09D2-ORDER-DRINK-500', name: '500ml', attributes: { volume: '500ml' },
|
||||
barcode: 'M09D2000001', imageUrl: '', salePriceCents: 700,
|
||||
marketPriceCents: 900, costPriceCents: 300,
|
||||
defaultInventoryPolicy: 'TRACKED', status: 'ACTIVE'
|
||||
});
|
||||
await catalog.putListing(adminActor, storeId, product.productId, 0, {
|
||||
categoryId, status: 'ACTIVE', fulfillmentMode: 'BOTH',
|
||||
salesStartAt: null, salesEndAt: null, sortOrder: 2
|
||||
});
|
||||
const configured = await inventory.configurePolicy(adminActor, {
|
||||
storeId, skuId: sku.skuId, requestId: 'm09d2-policy', reason: '订单测试库存策略',
|
||||
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
|
||||
});
|
||||
await inventory.inbound(adminActor, {
|
||||
storeId, skuId: sku.skuId, requestId: 'm09d2-inbound', reason: '订单测试入库',
|
||||
expectedVersion: configured.version, quantity: 20
|
||||
});
|
||||
|
||||
const firstInput = {
|
||||
storeId, requestId: 'm09d2-order-first', fulfillmentMode: 'SELF_SERVICE',
|
||||
roomOrderId: null, note: 'first order',
|
||||
items: [{ skuId: sku.skuId, quantity: 2, note: 'snapshot note' }]
|
||||
};
|
||||
const firstOrder = await service.create(customerActor, firstInput);
|
||||
assert.equal(firstOrder.status, 'PENDING_PAYMENT');
|
||||
assert.equal(firstOrder.totalAmountCents, 1400);
|
||||
assert.equal(firstOrder.items[0].productName, 'M09D2 Order Drink');
|
||||
assert.equal(firstOrder.items[0].unitPriceCents, 700);
|
||||
assert.equal((await service.create(customerActor, firstInput)).idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.create(customerActor, {
|
||||
...firstInput, items: [{ skuId: sku.skuId, quantity: 3, note: 'changed' }]
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-invalid-room-order', fulfillmentMode: 'DELIVERY',
|
||||
roomOrderId: '999999999999999999'
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_ROOM_ORDER_INVALID'
|
||||
);
|
||||
let [stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 2 });
|
||||
|
||||
const firstPayment = await service.createPaymentForCustomer(
|
||||
customerActor, firstOrder.id, { requestId: 'm09d2-payment-first', provider: 'TEST' }
|
||||
);
|
||||
assert.equal((await service.createPaymentForCustomer(
|
||||
customerActor, firstOrder.id, { requestId: 'm09d2-payment-first', provider: 'TEST' }
|
||||
)).idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.createPaymentForCustomer(customerActor, firstOrder.id, {
|
||||
requestId: 'm09d2-payment-first', provider: 'WECHAT'
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_PAYMENT_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
const paymentRace = await Promise.all([
|
||||
service.completeTestPaymentForCustomer(customerActor, firstPayment.id, {
|
||||
callbackId: 'm09d2-payment-first-callback-a', amountCents: 1400
|
||||
}),
|
||||
service.completeTestPaymentForCustomer(customerActor, firstPayment.id, {
|
||||
callbackId: 'm09d2-payment-first-callback-b', amountCents: 1400
|
||||
})
|
||||
]);
|
||||
assert.equal(paymentRace.filter((result) => result.idempotent === false).length, 1);
|
||||
assert.equal(paymentRace.filter((result) => result.idempotent === true).length, 1);
|
||||
[stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 0 });
|
||||
const [deductRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND business_type = 'PRODUCT_ORDER'
|
||||
AND business_id = ? AND operation = 'DEDUCT'`,
|
||||
[context.tenantId, `PRODUCT_ORDER:${firstOrder.orderNo}`]
|
||||
);
|
||||
assert.equal(Number(deductRows[0].total), 1,
|
||||
'concurrent payment callbacks must deduct inventory once');
|
||||
|
||||
await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-accept', action: 'ACCEPT', reason: ''
|
||||
});
|
||||
await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-ready', action: 'MARK_READY', reason: ''
|
||||
});
|
||||
const completed = await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-complete', action: 'COMPLETE', reason: ''
|
||||
});
|
||||
assert.equal(completed.status, 'COMPLETED');
|
||||
const completedRefunding = await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-refund', action: 'REFUND', reason: '完成后退款不回库'
|
||||
});
|
||||
assert.equal(completedRefunding.refunds[0].inventoryDisposition, 'NO_RESTOCK');
|
||||
await service.completeTestRefundForManagement(
|
||||
adminActor, String(completedRefunding.refunds[0].id), {
|
||||
callbackId: 'm09d2-first-refund-callback', amountCents: 1400
|
||||
}
|
||||
);
|
||||
|
||||
const restockOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-restock', note: 'restock order',
|
||||
items: [{ skuId: sku.skuId, quantity: 3, note: '' }]
|
||||
});
|
||||
const restockPayment = await service.createPaymentForCustomer(
|
||||
customerActor, restockOrder.id,
|
||||
{ requestId: 'm09d2-payment-restock', provider: 'TEST' }
|
||||
);
|
||||
await service.completeTestPaymentForCustomer(customerActor, restockPayment.id, {
|
||||
callbackId: 'm09d2-payment-restock-callback', amountCents: 2100
|
||||
});
|
||||
const restockRefunding = await service.managementAction(adminActor, restockOrder.id, {
|
||||
requestId: 'm09d2-restock-refund', action: 'REFUND', reason: '支付后取消回库'
|
||||
});
|
||||
const restockRefundId = String(restockRefunding.refunds[0].id);
|
||||
assert.equal(restockRefunding.refunds[0].inventoryDisposition, 'RESTOCK');
|
||||
const restocked = await service.completeTestRefundForManagement(
|
||||
adminActor, restockRefundId,
|
||||
{ callbackId: 'm09d2-restock-refund-callback', amountCents: 2100 }
|
||||
);
|
||||
assert.equal(restocked.status, 'SUCCEEDED');
|
||||
assert.equal((await service.completeTestRefundForManagement(
|
||||
adminActor, restockRefundId,
|
||||
{ callbackId: 'm09d2-restock-refund-callback', amountCents: 2100 }
|
||||
)).idempotent, true);
|
||||
|
||||
const cancelOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-cancel', note: 'cancel order',
|
||||
items: [{ skuId: sku.skuId, quantity: 1, note: '' }]
|
||||
});
|
||||
const cancelled = await service.cancelForCustomer(customerActor, cancelOrder.id, {
|
||||
requestId: 'm09d2-order-cancel-action', reason: '顾客取消待支付订单'
|
||||
});
|
||||
assert.equal(cancelled.status, 'CANCELLED');
|
||||
assert.equal(cancelled.inventoryStatus, 'RELEASED');
|
||||
assert.equal((await service.cancelForCustomer(customerActor, cancelOrder.id, {
|
||||
requestId: 'm09d2-order-cancel-action', reason: '顾客取消待支付订单'
|
||||
})).status, 'CANCELLED');
|
||||
|
||||
const timeoutOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-timeout', note: 'timeout order',
|
||||
items: [{ skuId: sku.skuId, quantity: 1, note: '' }]
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE qipai_product_orders SET expires_at = UTC_TIMESTAMP(3) - INTERVAL 1 SECOND
|
||||
WHERE tenant_id = ? AND id = ?`, [context.tenantId, timeoutOrder.id]
|
||||
);
|
||||
assert.ok((await service.expirePendingOrders()).expired >= 1);
|
||||
assert.equal((await service.getForCustomer(customerActor, timeoutOrder.id)).status, 'CANCELLED');
|
||||
|
||||
[stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 0 },
|
||||
'completed consumption stays deducted while paid cancellation and pending cancellation restore stock');
|
||||
|
||||
await assert.rejects(
|
||||
() => service.listForManagement({
|
||||
...adminActor,
|
||||
access: { roles: ['STAFF'], capabilities: ['goods.order.read'], storeIds: [] }
|
||||
}, { storeId, page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_order_items SET item_note = 'forbidden'
|
||||
WHERE tenant_id = ? AND order_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstOrder.id]
|
||||
),
|
||||
(error) => /PRODUCT_ORDER_ITEM_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`DELETE FROM qipai_product_order_events
|
||||
WHERE tenant_id = ? AND order_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstOrder.id]
|
||||
),
|
||||
(error) => /PRODUCT_ORDER_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_refund_events SET reason = 'forbidden'
|
||||
WHERE tenant_id = ? AND refund_id = ? LIMIT 1`,
|
||||
[context.tenantId, restockRefundId]
|
||||
),
|
||||
(error) => /PRODUCT_REFUND_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
console.log(
|
||||
'PASS: M09-D2 order snapshots, concurrent payment deduct, cancellation release, '
|
||||
+ 'refund restock, timeout cleanup and immutable events are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -4049,7 +4363,8 @@ try {
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -4073,13 +4388,14 @@ try {
|
||||
await assertIotMessages(pool, loginContext);
|
||||
await assertCleaningTaskTransactions(pool, loginContext);
|
||||
await assertProductInventoryFoundation(pool, loginContext);
|
||||
await assertProductOrderPaymentInventory(pool, loginContext);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M09-D1 migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-D2 migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -4110,7 +4426,8 @@ try {
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { ProductOrderError } from '../dist/products/product-order-service.js';
|
||||
import { registerProductOrderRoutes } from '../dist/routes/product-orders.js';
|
||||
|
||||
const secret = 'test-only-product-order-route-jwt-secret';
|
||||
const sessionId = '3a6ab573-1105-4bf9-b75b-e88e49eb3b82';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm09d2-product-order-route'
|
||||
};
|
||||
let currentAccess = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
|
||||
const calls = [];
|
||||
const record = (method, result) => async (...args) => {
|
||||
calls.push({ method, args });
|
||||
return typeof result === 'function' ? result(...args) : result;
|
||||
};
|
||||
const order = {
|
||||
id: '101', storeId: '11', orderNo: 'PG101', status: 'PENDING_PAYMENT',
|
||||
fulfillmentMode: 'SELF_SERVICE', totalAmountCents: 500,
|
||||
inventoryStatus: 'LOCKED'
|
||||
};
|
||||
const service = {
|
||||
create: record('create', (_actor, input) => ({ ...order, storeId: input.storeId })),
|
||||
listForCustomer: record('listForCustomer', (_actor, input) => ({
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForCustomer: record('getForCustomer', (_actor, orderId) => {
|
||||
if (orderId === '999') throw new ProductOrderError('PRODUCT_ORDER_NOT_FOUND');
|
||||
return order;
|
||||
}),
|
||||
cancelForCustomer: record('cancelForCustomer', () => ({ ...order, status: 'CANCELLED' })),
|
||||
listForManagement: record('listForManagement', (_actor, input) => ({
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForManagement: record('getForManagement', () => order),
|
||||
managementAction: record('managementAction', (_actor, _id, input) => ({
|
||||
...order, status: input.action === 'ACCEPT' ? 'ACCEPTED' : order.status
|
||||
})),
|
||||
createPaymentForCustomer: record('createPaymentForCustomer', () => ({
|
||||
id: '401', orderId: '101', provider: 'TEST', status: 'PENDING', amountCents: 500
|
||||
})),
|
||||
completeTestPaymentForCustomer: record('completeTestPaymentForCustomer', () => ({
|
||||
paymentId: '401', orderId: '101', status: 'SUCCEEDED'
|
||||
})),
|
||||
completeTestRefundForManagement: record('completeTestRefundForManagement', () => ({
|
||||
refundId: '501', orderId: '101', status: 'SUCCEEDED'
|
||||
}))
|
||||
};
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
app.decorateRequest('traceId', '');
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
request.traceId = request.headers['x-trace-id'] || request.id;
|
||||
reply.header('x-trace-id', request.traceId);
|
||||
});
|
||||
await registerProductOrderRoutes(app, {
|
||||
service,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: sessionId, tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return currentAccess; } },
|
||||
jwtSecret: secret,
|
||||
testAdapterEnabled: true
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/product-orders' });
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
|
||||
|
||||
const invalidDelivery = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'create-invalid', fulfillmentMode: 'DELIVERY',
|
||||
note: '', items: [{ skuId: '5', quantity: 1, note: '' }]
|
||||
}
|
||||
});
|
||||
assert.equal(invalidDelivery.statusCode, 400);
|
||||
assert.equal(invalidDelivery.json().code, 'PRODUCT_ORDER_INPUT_INVALID');
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'create-order-1', fulfillmentMode: 'SELF_SERVICE',
|
||||
note: '少冰', items: [{ skuId: '5', quantity: 2, note: '' }]
|
||||
}
|
||||
});
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.id, '101');
|
||||
const createCall = calls.find((call) => call.method === 'create');
|
||||
assert.equal(createCall.args[0].tenantId, '7');
|
||||
assert.equal(createCall.args[0].userId, '21');
|
||||
assert.equal(createCall.args[0].source, 'CUSTOMER');
|
||||
assert.equal(createCall.args[0].traceId, 'm09d2-product-order-route');
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-orders?storeId=11&page=2&pageSize=10', headers
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.json().data.page, 2);
|
||||
|
||||
const missing = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-orders/999', headers
|
||||
});
|
||||
assert.equal(missing.statusCode, 404);
|
||||
assert.equal(missing.json().code, 'PRODUCT_ORDER_NOT_FOUND');
|
||||
|
||||
const payment = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders/101/payments', headers,
|
||||
payload: { requestId: 'payment-create-1', provider: 'TEST' }
|
||||
});
|
||||
assert.equal(payment.statusCode, 201);
|
||||
assert.equal(payment.json().data.id, '401');
|
||||
|
||||
const paid = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-payments/401/test-complete', headers,
|
||||
payload: { callbackId: 'payment-callback-1', amountCents: 500 }
|
||||
});
|
||||
assert.equal(paid.statusCode, 200);
|
||||
assert.equal(paid.json().data.status, 'SUCCEEDED');
|
||||
|
||||
const managementForbidden = await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-orders?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementForbidden.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.order.read'], storeIds: ['11']
|
||||
};
|
||||
const managementList = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/product-orders?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementList.statusCode, 200);
|
||||
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||
|
||||
const readOnlyAction = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'accept-order-1', action: 'ACCEPT', reason: '' }
|
||||
});
|
||||
assert.equal(readOnlyAction.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.order.read', 'goods.order.manage'], storeIds: ['11']
|
||||
};
|
||||
const accepted = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'accept-order-1', action: 'ACCEPT', reason: '' }
|
||||
});
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
assert.equal(accepted.json().data.status, 'ACCEPTED');
|
||||
|
||||
const refundCompleted = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-refunds/501/test-complete', headers,
|
||||
payload: { callbackId: 'refund-callback-1', amountCents: 500 }
|
||||
});
|
||||
assert.equal(refundCompleted.statusCode, 200);
|
||||
assert.equal(refundCompleted.json().data.status, 'SUCCEEDED');
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D2 product order routes enforce customer ownership, management permissions and test payment boundaries.');
|
||||
@@ -0,0 +1,279 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ProductOrderError,
|
||||
ProductOrderService,
|
||||
productOrderFingerprint,
|
||||
resolveProductOrderTransition
|
||||
} from '../dist/products/product-order-service.js';
|
||||
|
||||
const fixedNow = new Date('2026-08-11T04:00:00.000Z');
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
access: { roles: ['CUSTOMER'], capabilities: [], storeIds: [] },
|
||||
source: 'CUSTOMER',
|
||||
traceId: 'm09d2-product-order-test',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'product-order-test'
|
||||
};
|
||||
const createInput = {
|
||||
storeId: '11',
|
||||
requestId: 'create-order-1',
|
||||
fulfillmentMode: 'SELF_SERVICE',
|
||||
roomOrderId: null,
|
||||
note: '少冰',
|
||||
items: [{ skuId: '5', quantity: 2, note: '分开放' }]
|
||||
};
|
||||
const fingerprint = productOrderFingerprint(createInput);
|
||||
|
||||
assert.equal(
|
||||
productOrderFingerprint({
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '6', quantity: 1, note: '' },
|
||||
{ skuId: '5', quantity: 2, note: '分开放' }
|
||||
]
|
||||
}),
|
||||
productOrderFingerprint({
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '5', quantity: 2, note: '分开放' },
|
||||
{ skuId: '6', quantity: 1, note: '' }
|
||||
]
|
||||
}),
|
||||
'semantic item ordering must not change the create fingerprint'
|
||||
);
|
||||
assert.notEqual(
|
||||
fingerprint,
|
||||
productOrderFingerprint({ ...createInput, note: '常温' }),
|
||||
'meaningful input changes must change the create fingerprint'
|
||||
);
|
||||
|
||||
assert.equal(resolveProductOrderTransition('PAID', 'DELIVERY', 'ACCEPT'), 'ACCEPTED');
|
||||
assert.equal(
|
||||
resolveProductOrderTransition('ACCEPTED', 'DELIVERY', 'START_DELIVERY'),
|
||||
'DELIVERING'
|
||||
);
|
||||
assert.equal(
|
||||
resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'MARK_READY'),
|
||||
'READY_FOR_SELF_SERVICE'
|
||||
);
|
||||
assert.equal(resolveProductOrderTransition('DELIVERING', 'DELIVERY', 'COMPLETE'), 'COMPLETED');
|
||||
assert.throws(
|
||||
() => resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'START_DELIVERY'),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_TRANSITION_NOT_ALLOWED'
|
||||
);
|
||||
|
||||
class ScriptedConnection {
|
||||
constructor(steps) {
|
||||
this.steps = [...steps];
|
||||
this.calls = [];
|
||||
this.committed = false;
|
||||
this.rolledBack = false;
|
||||
this.released = false;
|
||||
}
|
||||
|
||||
async beginTransaction() {}
|
||||
async commit() { this.committed = true; }
|
||||
async rollback() { this.rolledBack = true; }
|
||||
release() { this.released = true; }
|
||||
|
||||
async execute(sql, params = []) {
|
||||
this.calls.push({ sql, params });
|
||||
const step = this.steps.shift();
|
||||
assert.ok(step, `Unexpected SQL: ${sql}`);
|
||||
assert.match(sql, step.match);
|
||||
if (step.check) step.check(params, sql);
|
||||
return step.result;
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptedPool {
|
||||
constructor(directSteps, connections) {
|
||||
this.directSteps = [...directSteps];
|
||||
this.connections = [...connections];
|
||||
}
|
||||
|
||||
async execute(sql, params = []) {
|
||||
const step = this.directSteps.shift();
|
||||
assert.ok(step, `Unexpected direct SQL: ${sql}`);
|
||||
assert.match(sql, step.match);
|
||||
if (step.check) step.check(params, sql);
|
||||
return step.result;
|
||||
}
|
||||
|
||||
async getConnection() {
|
||||
const connection = this.connections.shift();
|
||||
assert.ok(connection, 'Unexpected transaction');
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
const baseOrder = {
|
||||
id: '101', tenantId: '7', storeId: '11', memberId: '21', roomOrderId: null,
|
||||
orderNo: 'PG202608110001', clientRequestId: createInput.requestId,
|
||||
requestFingerprint: fingerprint, inventoryBusinessId: 'PRODUCT_ORDER:PG202608110001',
|
||||
inventoryRequestId: 'po.lock.abc', fulfillmentMode: 'SELF_SERVICE',
|
||||
status: 'PENDING_PAYMENT', itemCount: 1, totalQuantity: 2,
|
||||
totalAmountCents: 500, paidAmountCents: 0, refundedAmountCents: 0,
|
||||
orderNote: '少冰', inventoryStatus: 'LOCKED',
|
||||
expiresAt: new Date('2026-08-11T04:15:00.000Z'),
|
||||
paidAt: null, acceptedAt: null, deliveringAt: null, completedAt: null,
|
||||
cancelledAt: null, createdSource: 'MEMBER', createdBy: null,
|
||||
version: 1, createdAt: fixedNow, updatedAt: fixedNow
|
||||
};
|
||||
const itemView = [{
|
||||
id: '201', lineNo: 1, productId: '3', skuId: '5', productCode: 'TEA',
|
||||
productName: '茶饮', skuCode: 'TEA-L', skuName: '大杯', unitName: '杯',
|
||||
attributes: null, unitPriceCents: 250, quantity: 2, subtotalCents: 500,
|
||||
note: '分开放'
|
||||
}];
|
||||
const detailSteps = (order = baseOrder) => [
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?/, result: [[order], []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*ORDER BY line_no/, result: [itemView, []] },
|
||||
{ match: /FROM qipai_product_payments[\s\S]*ORDER BY id DESC/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_refunds[\s\S]*ORDER BY id DESC/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_order_events[\s\S]*ORDER BY version_after/, result: [[{
|
||||
id: '301', fromStatus: null, toStatus: order.status, action: 'CREATE',
|
||||
actorType: 'MEMBER', actorId: null, versionAfter: order.version,
|
||||
reason: '', createdAt: fixedNow
|
||||
}], []] }
|
||||
];
|
||||
|
||||
const createConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?[\s\S]*FOR UPDATE/, result: [[], []] },
|
||||
{ match: /FROM qipai_stores s[\s\S]*FOR SHARE/, result: [[{
|
||||
id: null, timezone: 'Asia/Shanghai', businessStatus: 'OPEN', salesStatus: null,
|
||||
manualPausedAt: null, manualPausedUntil: null
|
||||
}], []] },
|
||||
{ match: /FROM qipai_product_skus s[\s\S]*FOR SHARE/, result: [[{
|
||||
skuId: '5', productId: '3', skuCode: 'TEA-L', skuName: '大杯',
|
||||
attributesSnapshot: null, unitPriceCents: 250, productCode: 'TEA',
|
||||
productName: '茶饮', unitName: '杯', deliveryEnabled: 1,
|
||||
fulfillmentMode: 'BOTH', salesStartAt: null, salesEndAt: null
|
||||
}], []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_product_orders/,
|
||||
result: [{ insertId: 101, affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[1], '11');
|
||||
assert.equal(params[2], '21');
|
||||
assert.equal(params[5], createInput.requestId);
|
||||
assert.equal(params[6], fingerprint);
|
||||
assert.equal(params[12], 500);
|
||||
}
|
||||
},
|
||||
{ match: /INSERT INTO qipai_product_order_items/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps()
|
||||
]);
|
||||
const createPool = new ScriptedPool([
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?/, result: [[], []] }
|
||||
], [createConnection]);
|
||||
let lockCall;
|
||||
const created = await new ProductOrderService(createPool, {
|
||||
async lockMany(input, connection) {
|
||||
lockCall = { input, connection };
|
||||
return { idempotent: false };
|
||||
},
|
||||
async releaseMany() { throw new Error('not expected'); },
|
||||
async deductMany() { throw new Error('not expected'); },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).create(actor, createInput);
|
||||
assert.equal(created.id, '101');
|
||||
assert.equal(created.totalAmountCents, 500);
|
||||
assert.equal(created.items[0].skuId, '5');
|
||||
assert.equal(lockCall.input.businessType, 'PRODUCT_ORDER');
|
||||
assert.deepEqual(lockCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(lockCall.connection, createConnection);
|
||||
assert.equal(createConnection.committed, true);
|
||||
assert.equal(createConnection.rolledBack, false);
|
||||
assert.equal(createConnection.released, true);
|
||||
assert.equal(createConnection.steps.length, 0);
|
||||
|
||||
const noSqlPool = new ScriptedPool([], []);
|
||||
await assert.rejects(
|
||||
() => new ProductOrderService(noSqlPool, {}).create(actor, {
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '5', quantity: 1, note: '' },
|
||||
{ skuId: '5', quantity: 1, note: 'duplicate' }
|
||||
]
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError && error.code === 'PRODUCT_ORDER_SKU_DUPLICATE'
|
||||
);
|
||||
|
||||
const pendingPayment = {
|
||||
id: '401', tenantId: '7', storeId: '11', orderId: '101',
|
||||
paymentNo: 'PP202608110001', clientRequestId: 'payment-create-1',
|
||||
requestFingerprint: 'a'.repeat(64), provider: 'TEST', channel: 'TEST',
|
||||
status: 'PENDING', amountCents: 500, refundedAmountCents: 0,
|
||||
expiresAt: baseOrder.expiresAt, paidAt: null, version: 1
|
||||
};
|
||||
const paymentConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_payment_callbacks[\s\S]*FOR UPDATE/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_payments WHERE[\s\S]*FOR UPDATE/, result: [[pendingPayment], []] },
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
|
||||
{ match: /INSERT INTO qipai_product_payment_callbacks/, result: [{ insertId: 501, affectedRows: 1 }, []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*SUCCEEDED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*status = 'PAID'/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payment_callbacks/, result: [{ affectedRows: 1 }, []] }
|
||||
]);
|
||||
const paymentPool = new ScriptedPool([{
|
||||
match: /o\.member_id AS memberId[\s\S]*FROM qipai_product_payments p/,
|
||||
result: [[{ ...pendingPayment, memberId: '21' }], []]
|
||||
}], [paymentConnection]);
|
||||
let deductCall;
|
||||
const paymentResult = await new ProductOrderService(paymentPool, {
|
||||
async lockMany() { throw new Error('not expected'); },
|
||||
async releaseMany() { throw new Error('not expected'); },
|
||||
async deductMany(input, connection) { deductCall = { input, connection }; },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).completeTestPaymentForCustomer(actor, '401', {
|
||||
callbackId: 'test-payment-callback-1', amountCents: 500
|
||||
});
|
||||
assert.deepEqual(paymentResult, {
|
||||
paymentId: '401', orderId: '101', status: 'SUCCEEDED', idempotent: false
|
||||
});
|
||||
assert.deepEqual(deductCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(deductCall.input.businessId, baseOrder.inventoryBusinessId);
|
||||
assert.equal(deductCall.connection, paymentConnection);
|
||||
assert.equal(paymentConnection.committed, true);
|
||||
assert.equal(paymentConnection.steps.length, 0);
|
||||
|
||||
const cancelledOrder = {
|
||||
...baseOrder, status: 'CANCELLED', inventoryStatus: 'RELEASED',
|
||||
cancelledAt: fixedNow, version: 2
|
||||
};
|
||||
const cancelConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_order_events[\s\S]*request_id = \?/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*CANCELLED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*CLOSED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps(cancelledOrder)
|
||||
]);
|
||||
let releaseCall;
|
||||
const cancelled = await new ProductOrderService(new ScriptedPool([], [cancelConnection]), {
|
||||
async lockMany() { throw new Error('not expected'); },
|
||||
async releaseMany(input, connection) { releaseCall = { input, connection }; },
|
||||
async deductMany() { throw new Error('not expected'); },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).cancelForCustomer(actor, '101', {
|
||||
requestId: 'cancel-order-1', reason: '顾客取消'
|
||||
});
|
||||
assert.equal(cancelled.status, 'CANCELLED');
|
||||
assert.equal(cancelled.inventoryStatus, 'RELEASED');
|
||||
assert.deepEqual(releaseCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(releaseCall.connection, cancelConnection);
|
||||
assert.equal(cancelConnection.committed, true);
|
||||
assert.equal(cancelConnection.steps.length, 0);
|
||||
|
||||
console.log('PASS: M09-D2 product order snapshot, transition, payment deduct and cancellation release service contracts work.');
|
||||
@@ -0,0 +1,23 @@
|
||||
DROP TRIGGER IF EXISTS qipai_product_refund_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS qipai_product_refund_events_no_update;
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_events_no_update;
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_items_no_delete;
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_items_no_update;
|
||||
|
||||
DELETE rp FROM qipai_role_permissions rp
|
||||
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||
WHERE p.code IN ('goods.order.read', 'goods.order.manage');
|
||||
|
||||
DELETE FROM qipai_permissions
|
||||
WHERE code IN ('goods.order.read', 'goods.order.manage');
|
||||
|
||||
DROP TABLE IF EXISTS qipai_product_refund_events;
|
||||
DROP TABLE IF EXISTS qipai_product_payment_callbacks;
|
||||
DROP TABLE IF EXISTS qipai_product_refunds;
|
||||
DROP TABLE IF EXISTS qipai_product_payments;
|
||||
DROP TABLE IF EXISTS qipai_product_order_events;
|
||||
DROP TABLE IF EXISTS qipai_product_order_items;
|
||||
DROP TABLE IF EXISTS qipai_product_orders;
|
||||
|
||||
DELETE FROM qipai_schema_migrations WHERE version = '2026081108';
|
||||
@@ -0,0 +1,459 @@
|
||||
CREATE TABLE IF NOT EXISTS qipai_product_orders (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
member_id BIGINT UNSIGNED NULL,
|
||||
room_order_id BIGINT UNSIGNED NULL,
|
||||
order_no VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
client_request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
request_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
inventory_business_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL,
|
||||
inventory_request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
fulfillment_mode VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PAYMENT',
|
||||
item_count SMALLINT UNSIGNED NOT NULL,
|
||||
total_quantity BIGINT UNSIGNED NOT NULL,
|
||||
total_amount_cents BIGINT UNSIGNED NOT NULL,
|
||||
paid_amount_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
refunded_amount_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
order_note VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
inventory_status VARCHAR(16) NOT NULL DEFAULT 'LOCKED',
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
paid_at DATETIME(3) NULL,
|
||||
accepted_at DATETIME(3) NULL,
|
||||
delivering_at DATETIME(3) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
cancelled_at DATETIME(3) NULL,
|
||||
created_source VARCHAR(16) NOT NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_product_order_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_product_order_store
|
||||
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
|
||||
CONSTRAINT fk_qipai_product_order_inventory_request
|
||||
FOREIGN KEY (tenant_id, inventory_request_id)
|
||||
REFERENCES qipai_product_inventory_requests(tenant_id, request_id),
|
||||
CONSTRAINT fk_qipai_product_order_created_by
|
||||
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
|
||||
UNIQUE KEY uq_qipai_product_order_scope_id (tenant_id, store_id, id),
|
||||
UNIQUE KEY uq_qipai_product_order_no (tenant_id, order_no),
|
||||
UNIQUE KEY uq_qipai_product_order_client_request (tenant_id, client_request_id),
|
||||
UNIQUE KEY uq_qipai_product_order_inventory_business
|
||||
(tenant_id, inventory_business_id),
|
||||
UNIQUE KEY uq_qipai_product_order_inventory_request
|
||||
(tenant_id, inventory_request_id),
|
||||
KEY idx_qipai_product_order_store_status
|
||||
(tenant_id, store_id, status, created_at, id),
|
||||
KEY idx_qipai_product_order_room
|
||||
(tenant_id, store_id, room_order_id, created_at),
|
||||
KEY idx_qipai_product_order_member
|
||||
(tenant_id, member_id, created_at, id),
|
||||
CONSTRAINT chk_qipai_product_order_fulfillment CHECK (
|
||||
(fulfillment_mode = 'DELIVERY' AND room_order_id IS NOT NULL)
|
||||
OR (fulfillment_mode = 'SELF_SERVICE' AND room_order_id IS NULL)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_status CHECK (
|
||||
status IN (
|
||||
'PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING',
|
||||
'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED',
|
||||
'REFUNDING', 'REFUNDED', 'REFUND_FAILED'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_counts CHECK (
|
||||
item_count BETWEEN 1 AND 100
|
||||
AND total_quantity BETWEEN 1 AND 1000000000
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_amounts CHECK (
|
||||
total_amount_cents BETWEEN 1 AND 100000000000000
|
||||
AND paid_amount_cents <= total_amount_cents
|
||||
AND refunded_amount_cents <= paid_amount_cents
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_inventory_status CHECK (
|
||||
inventory_status IN ('LOCKED', 'DEDUCTED', 'RELEASED', 'RETURNED')
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_source CHECK (
|
||||
created_source IN ('MEMBER', 'STAFF', 'SYSTEM')
|
||||
AND (created_source <> 'MEMBER' OR member_id IS NOT NULL)
|
||||
AND (created_source <> 'STAFF' OR created_by IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_fingerprint CHECK (
|
||||
request_fingerprint REGEXP '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_version CHECK (version >= 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_product_order_items (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
line_no SMALLINT UNSIGNED NOT NULL,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
sku_id BIGINT UNSIGNED NOT NULL,
|
||||
product_code VARCHAR(64) NOT NULL,
|
||||
product_name VARCHAR(128) NOT NULL,
|
||||
sku_code VARCHAR(64) NOT NULL,
|
||||
sku_name VARCHAR(128) NOT NULL,
|
||||
unit_name VARCHAR(32) NOT NULL,
|
||||
attributes_snapshot JSON NULL,
|
||||
unit_price_cents INT UNSIGNED NOT NULL,
|
||||
quantity BIGINT UNSIGNED NOT NULL,
|
||||
subtotal_cents BIGINT UNSIGNED NOT NULL,
|
||||
item_note VARCHAR(512) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_product_order_item_order
|
||||
FOREIGN KEY (tenant_id, store_id, order_id)
|
||||
REFERENCES qipai_product_orders(tenant_id, store_id, id),
|
||||
CONSTRAINT fk_qipai_product_order_item_product
|
||||
FOREIGN KEY (tenant_id, product_id) REFERENCES qipai_products(tenant_id, id),
|
||||
CONSTRAINT fk_qipai_product_order_item_sku
|
||||
FOREIGN KEY (tenant_id, sku_id) REFERENCES qipai_product_skus(tenant_id, id),
|
||||
UNIQUE KEY uq_qipai_product_order_item_line
|
||||
(tenant_id, store_id, order_id, line_no),
|
||||
KEY idx_qipai_product_order_item_sku
|
||||
(tenant_id, store_id, sku_id, created_at),
|
||||
CONSTRAINT chk_qipai_product_order_item_line CHECK (line_no BETWEEN 1 AND 100),
|
||||
CONSTRAINT chk_qipai_product_order_item_amount CHECK (
|
||||
unit_price_cents BETWEEN 1 AND 100000000
|
||||
AND quantity BETWEEN 1 AND 1000000000
|
||||
AND subtotal_cents = unit_price_cents * quantity
|
||||
AND subtotal_cents <= 100000000000000
|
||||
)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_product_order_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
from_status VARCHAR(32) NULL,
|
||||
to_status VARCHAR(32) NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
actor_type VARCHAR(16) NOT NULL,
|
||||
actor_id BIGINT UNSIGNED NULL,
|
||||
inventory_request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
version_after INT UNSIGNED NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
trace_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
metadata JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_product_order_event_order
|
||||
FOREIGN KEY (tenant_id, store_id, order_id)
|
||||
REFERENCES qipai_product_orders(tenant_id, store_id, id),
|
||||
CONSTRAINT fk_qipai_product_order_event_actor
|
||||
FOREIGN KEY (tenant_id, actor_id) REFERENCES qipai_users(tenant_id, id),
|
||||
CONSTRAINT fk_qipai_product_order_event_inventory_request
|
||||
FOREIGN KEY (tenant_id, inventory_request_id)
|
||||
REFERENCES qipai_product_inventory_requests(tenant_id, request_id),
|
||||
UNIQUE KEY uq_qipai_product_order_event_request (tenant_id, request_id),
|
||||
UNIQUE KEY uq_qipai_product_order_event_version
|
||||
(tenant_id, store_id, order_id, version_after),
|
||||
KEY idx_qipai_product_order_event_history
|
||||
(tenant_id, store_id, order_id, created_at, id),
|
||||
CONSTRAINT chk_qipai_product_order_event_from_status CHECK (
|
||||
from_status IS NULL OR from_status IN (
|
||||
'PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING',
|
||||
'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED',
|
||||
'REFUNDING', 'REFUNDED', 'REFUND_FAILED'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_event_to_status CHECK (
|
||||
to_status IN (
|
||||
'PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING',
|
||||
'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED',
|
||||
'REFUNDING', 'REFUNDED', 'REFUND_FAILED'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_event_actor CHECK (
|
||||
actor_type IN ('MEMBER', 'STAFF', 'CALLBACK', 'SYSTEM')
|
||||
AND (actor_type <> 'STAFF' OR actor_id IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_order_event_version CHECK (version_after >= 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_product_payments (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
payment_config_id BIGINT UNSIGNED NULL,
|
||||
payment_no VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
client_request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
request_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
provider VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
channel VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
amount_cents BIGINT UNSIGNED NOT NULL,
|
||||
refunded_amount_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
currency CHAR(3) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'CNY',
|
||||
provider_payment_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
provider_prepay_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
paid_at DATETIME(3) NULL,
|
||||
failed_at DATETIME(3) NULL,
|
||||
failure_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
|
||||
raw_response JSON NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
active_order_id BIGINT UNSIGNED
|
||||
GENERATED ALWAYS AS (
|
||||
CASE WHEN status IN ('PENDING', 'PROCESSING') THEN order_id ELSE NULL END
|
||||
) STORED,
|
||||
CONSTRAINT fk_qipai_product_payment_order
|
||||
FOREIGN KEY (tenant_id, store_id, order_id)
|
||||
REFERENCES qipai_product_orders(tenant_id, store_id, id),
|
||||
CONSTRAINT fk_qipai_product_payment_config
|
||||
FOREIGN KEY (payment_config_id) REFERENCES qipai_payment_configs(id),
|
||||
UNIQUE KEY uq_qipai_product_payment_scope_id
|
||||
(tenant_id, store_id, order_id, id),
|
||||
UNIQUE KEY uq_qipai_product_payment_no (tenant_id, payment_no),
|
||||
UNIQUE KEY uq_qipai_product_payment_client_request
|
||||
(tenant_id, client_request_id),
|
||||
UNIQUE KEY uq_qipai_product_payment_provider_id
|
||||
(tenant_id, provider, provider_payment_id),
|
||||
UNIQUE KEY uq_qipai_product_payment_active_order
|
||||
(tenant_id, store_id, active_order_id),
|
||||
KEY idx_qipai_product_payment_status
|
||||
(tenant_id, store_id, status, created_at, id),
|
||||
CONSTRAINT chk_qipai_product_payment_status CHECK (
|
||||
status IN (
|
||||
'PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CLOSED',
|
||||
'COMPENSATION_REQUIRED', 'REFUNDED'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_payment_amount CHECK (
|
||||
amount_cents BETWEEN 1 AND 100000000000000
|
||||
AND refunded_amount_cents <= amount_cents
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_payment_currency CHECK (currency = 'CNY'),
|
||||
CONSTRAINT chk_qipai_product_payment_fingerprint CHECK (
|
||||
request_fingerprint REGEXP '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_payment_version CHECK (version >= 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_product_refunds (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
payment_id BIGINT UNSIGNED NOT NULL,
|
||||
refund_no VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
client_request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
request_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
provider VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
provider_refund_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
amount_cents BIGINT UNSIGNED NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL,
|
||||
inventory_disposition VARCHAR(16) NOT NULL,
|
||||
inventory_status VARCHAR(16) NOT NULL,
|
||||
inventory_request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
requested_by BIGINT UNSIGNED NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
failure_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
|
||||
raw_response JSON NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
active_payment_id BIGINT UNSIGNED
|
||||
GENERATED ALWAYS AS (
|
||||
CASE WHEN status IN ('PENDING', 'PROCESSING') THEN payment_id ELSE NULL END
|
||||
) STORED,
|
||||
CONSTRAINT fk_qipai_product_refund_payment
|
||||
FOREIGN KEY (tenant_id, store_id, order_id, payment_id)
|
||||
REFERENCES qipai_product_payments(tenant_id, store_id, order_id, id),
|
||||
CONSTRAINT fk_qipai_product_refund_inventory_request
|
||||
FOREIGN KEY (tenant_id, inventory_request_id)
|
||||
REFERENCES qipai_product_inventory_requests(tenant_id, request_id),
|
||||
CONSTRAINT fk_qipai_product_refund_requested_by
|
||||
FOREIGN KEY (tenant_id, requested_by) REFERENCES qipai_users(tenant_id, id),
|
||||
UNIQUE KEY uq_qipai_product_refund_scope_id
|
||||
(tenant_id, store_id, order_id, payment_id, id),
|
||||
UNIQUE KEY uq_qipai_product_refund_no (tenant_id, refund_no),
|
||||
UNIQUE KEY uq_qipai_product_refund_client_request
|
||||
(tenant_id, client_request_id),
|
||||
UNIQUE KEY uq_qipai_product_refund_provider_id
|
||||
(tenant_id, provider, provider_refund_id),
|
||||
UNIQUE KEY uq_qipai_product_refund_inventory_request
|
||||
(tenant_id, inventory_request_id),
|
||||
UNIQUE KEY uq_qipai_product_refund_active_payment
|
||||
(tenant_id, store_id, order_id, active_payment_id),
|
||||
KEY idx_qipai_product_refund_status
|
||||
(tenant_id, store_id, status, created_at, id),
|
||||
CONSTRAINT chk_qipai_product_refund_status CHECK (
|
||||
status IN ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED')
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_amount CHECK (
|
||||
amount_cents BETWEEN 1 AND 100000000000000
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_inventory CHECK (
|
||||
(inventory_disposition = 'RESTOCK'
|
||||
AND inventory_status IN ('PENDING', 'SUCCEEDED', 'FAILED')
|
||||
AND (inventory_status = 'PENDING' OR inventory_request_id IS NOT NULL))
|
||||
OR (inventory_disposition = 'NO_RESTOCK'
|
||||
AND inventory_status = 'NOT_REQUIRED'
|
||||
AND inventory_request_id IS NULL)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_reason CHECK (CHAR_LENGTH(reason) BETWEEN 1 AND 512),
|
||||
CONSTRAINT chk_qipai_product_refund_fingerprint CHECK (
|
||||
request_fingerprint REGEXP '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_version CHECK (version >= 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_product_payment_callbacks (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
payment_id BIGINT UNSIGNED NOT NULL,
|
||||
refund_id BIGINT UNSIGNED NULL,
|
||||
provider VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
callback_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
callback_type VARCHAR(16) NOT NULL,
|
||||
payload_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
processing_status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED',
|
||||
payload JSON NOT NULL,
|
||||
error_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
|
||||
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
processed_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_product_payment_callback_payment
|
||||
FOREIGN KEY (tenant_id, store_id, order_id, payment_id)
|
||||
REFERENCES qipai_product_payments(tenant_id, store_id, order_id, id),
|
||||
CONSTRAINT fk_qipai_product_payment_callback_refund
|
||||
FOREIGN KEY (tenant_id, store_id, order_id, payment_id, refund_id)
|
||||
REFERENCES qipai_product_refunds(tenant_id, store_id, order_id, payment_id, id),
|
||||
UNIQUE KEY uq_qipai_product_payment_callback_provider
|
||||
(tenant_id, provider, callback_id),
|
||||
KEY idx_qipai_product_payment_callback_status
|
||||
(tenant_id, store_id, processing_status, received_at, id),
|
||||
CONSTRAINT chk_qipai_product_payment_callback_type CHECK (
|
||||
(callback_type = 'PAYMENT' AND refund_id IS NULL)
|
||||
OR (callback_type = 'REFUND' AND refund_id IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_payment_callback_verified CHECK (verified IN (0, 1)),
|
||||
CONSTRAINT chk_qipai_product_payment_callback_status CHECK (
|
||||
processing_status IN ('RECEIVED', 'PROCESSED', 'FAILED', 'IGNORED')
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_payment_callback_fingerprint CHECK (
|
||||
payload_fingerprint REGEXP '^[0-9a-f]{64}$'
|
||||
)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_product_refund_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
payment_id BIGINT UNSIGNED NOT NULL,
|
||||
refund_id BIGINT UNSIGNED NOT NULL,
|
||||
request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
from_status VARCHAR(16) NULL,
|
||||
to_status VARCHAR(16) NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
inventory_status VARCHAR(16) NOT NULL,
|
||||
inventory_request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
actor_type VARCHAR(16) NOT NULL,
|
||||
actor_id BIGINT UNSIGNED NULL,
|
||||
version_after INT UNSIGNED NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
trace_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
metadata JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_product_refund_event_refund
|
||||
FOREIGN KEY (tenant_id, store_id, order_id, payment_id, refund_id)
|
||||
REFERENCES qipai_product_refunds(tenant_id, store_id, order_id, payment_id, id),
|
||||
CONSTRAINT fk_qipai_product_refund_event_inventory_request
|
||||
FOREIGN KEY (tenant_id, inventory_request_id)
|
||||
REFERENCES qipai_product_inventory_requests(tenant_id, request_id),
|
||||
CONSTRAINT fk_qipai_product_refund_event_actor
|
||||
FOREIGN KEY (tenant_id, actor_id) REFERENCES qipai_users(tenant_id, id),
|
||||
UNIQUE KEY uq_qipai_product_refund_event_request (tenant_id, request_id),
|
||||
UNIQUE KEY uq_qipai_product_refund_event_version
|
||||
(tenant_id, store_id, refund_id, version_after),
|
||||
KEY idx_qipai_product_refund_event_history
|
||||
(tenant_id, store_id, refund_id, created_at, id),
|
||||
CONSTRAINT chk_qipai_product_refund_event_from_status CHECK (
|
||||
from_status IS NULL OR from_status IN ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED')
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_event_to_status CHECK (
|
||||
to_status IN ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED')
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_event_inventory_status CHECK (
|
||||
inventory_status IN ('PENDING', 'SUCCEEDED', 'FAILED', 'NOT_REQUIRED')
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_event_actor CHECK (
|
||||
actor_type IN ('STAFF', 'CALLBACK', 'SYSTEM')
|
||||
AND (actor_type <> 'STAFF' OR actor_id IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT chk_qipai_product_refund_event_version CHECK (version_after >= 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_events_no_update;
|
||||
|
||||
CREATE TRIGGER qipai_product_order_events_no_update
|
||||
BEFORE UPDATE ON qipai_product_order_events
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PRODUCT_ORDER_EVENT_IMMUTABLE';
|
||||
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_events_no_delete;
|
||||
|
||||
CREATE TRIGGER qipai_product_order_events_no_delete
|
||||
BEFORE DELETE ON qipai_product_order_events
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PRODUCT_ORDER_EVENT_IMMUTABLE';
|
||||
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_items_no_update;
|
||||
|
||||
CREATE TRIGGER qipai_product_order_items_no_update
|
||||
BEFORE UPDATE ON qipai_product_order_items
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PRODUCT_ORDER_ITEM_IMMUTABLE';
|
||||
|
||||
DROP TRIGGER IF EXISTS qipai_product_order_items_no_delete;
|
||||
|
||||
CREATE TRIGGER qipai_product_order_items_no_delete
|
||||
BEFORE DELETE ON qipai_product_order_items
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PRODUCT_ORDER_ITEM_IMMUTABLE';
|
||||
|
||||
DROP TRIGGER IF EXISTS qipai_product_refund_events_no_update;
|
||||
|
||||
CREATE TRIGGER qipai_product_refund_events_no_update
|
||||
BEFORE UPDATE ON qipai_product_refund_events
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PRODUCT_REFUND_EVENT_IMMUTABLE';
|
||||
|
||||
DROP TRIGGER IF EXISTS qipai_product_refund_events_no_delete;
|
||||
|
||||
CREATE TRIGGER qipai_product_refund_events_no_delete
|
||||
BEFORE DELETE ON qipai_product_refund_events
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'PRODUCT_REFUND_EVENT_IMMUTABLE';
|
||||
|
||||
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
|
||||
('goods.order.read', '查看商品订单', 'goods'),
|
||||
('goods.order.manage', '处理商品订单', 'goods');
|
||||
|
||||
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
|
||||
SELECT r.tenant_id, r.id, p.id
|
||||
FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p
|
||||
ON p.code IN ('goods.order.read', 'goods.order.manage')
|
||||
WHERE r.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND r.status = 'ACTIVE'
|
||||
AND r.deleted_at IS NULL;
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026081108', 'm09d2_product_order_payment_inventory');
|
||||
@@ -0,0 +1,197 @@
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN (
|
||||
'qipai_product_orders',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_refund_events'
|
||||
)
|
||||
ORDER BY table_name;
|
||||
|
||||
SELECT table_name, column_name, data_type, is_nullable, collation_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND (
|
||||
(table_name = 'qipai_product_orders'
|
||||
AND column_name IN (
|
||||
'order_no', 'client_request_id', 'request_fingerprint', 'inventory_business_id',
|
||||
'inventory_request_id', 'fulfillment_mode', 'status',
|
||||
'total_amount_cents', 'inventory_status', 'version', 'deleted_at'
|
||||
))
|
||||
OR (table_name = 'qipai_product_order_items'
|
||||
AND column_name IN (
|
||||
'product_code', 'product_name', 'sku_code', 'sku_name',
|
||||
'unit_price_cents', 'quantity', 'subtotal_cents', 'item_note'
|
||||
))
|
||||
OR (table_name = 'qipai_product_order_events'
|
||||
AND column_name IN ('request_id', 'from_status', 'to_status', 'version_after'))
|
||||
OR (table_name = 'qipai_product_payments'
|
||||
AND column_name IN (
|
||||
'payment_no', 'client_request_id', 'request_fingerprint', 'provider', 'provider_payment_id',
|
||||
'status', 'amount_cents', 'refunded_amount_cents', 'version', 'active_order_id'
|
||||
))
|
||||
OR (table_name = 'qipai_product_refunds'
|
||||
AND column_name IN (
|
||||
'refund_no', 'client_request_id', 'request_fingerprint', 'provider_refund_id', 'status',
|
||||
'amount_cents', 'inventory_disposition', 'inventory_status',
|
||||
'inventory_request_id', 'version', 'active_payment_id'
|
||||
))
|
||||
OR (table_name = 'qipai_product_payment_callbacks'
|
||||
AND column_name IN (
|
||||
'callback_id', 'callback_type', 'payload_fingerprint',
|
||||
'processing_status', 'refund_id'
|
||||
))
|
||||
OR (table_name = 'qipai_product_refund_events'
|
||||
AND column_name IN (
|
||||
'request_id', 'from_status', 'to_status', 'inventory_status', 'version_after'
|
||||
))
|
||||
)
|
||||
ORDER BY table_name, ordinal_position;
|
||||
|
||||
SELECT table_name, index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND index_name IN (
|
||||
'uq_qipai_product_order_scope_id',
|
||||
'uq_qipai_product_order_no',
|
||||
'uq_qipai_product_order_client_request',
|
||||
'uq_qipai_product_order_inventory_business',
|
||||
'uq_qipai_product_order_inventory_request',
|
||||
'uq_qipai_product_order_item_line',
|
||||
'uq_qipai_product_order_event_request',
|
||||
'uq_qipai_product_order_event_version',
|
||||
'uq_qipai_product_payment_scope_id',
|
||||
'uq_qipai_product_payment_no',
|
||||
'uq_qipai_product_payment_client_request',
|
||||
'uq_qipai_product_payment_provider_id',
|
||||
'uq_qipai_product_payment_active_order',
|
||||
'uq_qipai_product_refund_scope_id',
|
||||
'uq_qipai_product_refund_no',
|
||||
'uq_qipai_product_refund_client_request',
|
||||
'uq_qipai_product_refund_provider_id',
|
||||
'uq_qipai_product_refund_inventory_request',
|
||||
'uq_qipai_product_refund_active_payment',
|
||||
'uq_qipai_product_payment_callback_provider',
|
||||
'uq_qipai_product_refund_event_request',
|
||||
'uq_qipai_product_refund_event_version'
|
||||
)
|
||||
GROUP BY table_name, index_name
|
||||
ORDER BY table_name, index_name;
|
||||
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_type = 'FOREIGN KEY'
|
||||
AND constraint_name IN (
|
||||
'fk_qipai_product_order_store',
|
||||
'fk_qipai_product_order_inventory_request',
|
||||
'fk_qipai_product_order_created_by',
|
||||
'fk_qipai_product_order_item_order',
|
||||
'fk_qipai_product_order_item_product',
|
||||
'fk_qipai_product_order_item_sku',
|
||||
'fk_qipai_product_order_event_order',
|
||||
'fk_qipai_product_order_event_actor',
|
||||
'fk_qipai_product_order_event_inventory_request',
|
||||
'fk_qipai_product_payment_order',
|
||||
'fk_qipai_product_refund_payment',
|
||||
'fk_qipai_product_refund_inventory_request',
|
||||
'fk_qipai_product_refund_requested_by',
|
||||
'fk_qipai_product_payment_callback_payment',
|
||||
'fk_qipai_product_payment_callback_refund',
|
||||
'fk_qipai_product_refund_event_refund',
|
||||
'fk_qipai_product_refund_event_inventory_request',
|
||||
'fk_qipai_product_refund_event_actor'
|
||||
)
|
||||
ORDER BY constraint_name;
|
||||
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_type = 'CHECK'
|
||||
AND constraint_name IN (
|
||||
'chk_qipai_product_order_fulfillment',
|
||||
'chk_qipai_product_order_status',
|
||||
'chk_qipai_product_order_counts',
|
||||
'chk_qipai_product_order_amounts',
|
||||
'chk_qipai_product_order_inventory_status',
|
||||
'chk_qipai_product_order_source',
|
||||
'chk_qipai_product_order_fingerprint',
|
||||
'chk_qipai_product_order_version',
|
||||
'chk_qipai_product_order_item_line',
|
||||
'chk_qipai_product_order_item_amount',
|
||||
'chk_qipai_product_order_event_from_status',
|
||||
'chk_qipai_product_order_event_to_status',
|
||||
'chk_qipai_product_order_event_actor',
|
||||
'chk_qipai_product_order_event_version',
|
||||
'chk_qipai_product_payment_status',
|
||||
'chk_qipai_product_payment_amount',
|
||||
'chk_qipai_product_payment_currency',
|
||||
'chk_qipai_product_payment_fingerprint',
|
||||
'chk_qipai_product_payment_version',
|
||||
'chk_qipai_product_refund_status',
|
||||
'chk_qipai_product_refund_amount',
|
||||
'chk_qipai_product_refund_inventory',
|
||||
'chk_qipai_product_refund_reason',
|
||||
'chk_qipai_product_refund_fingerprint',
|
||||
'chk_qipai_product_refund_version',
|
||||
'chk_qipai_product_payment_callback_type',
|
||||
'chk_qipai_product_payment_callback_verified',
|
||||
'chk_qipai_product_payment_callback_status',
|
||||
'chk_qipai_product_payment_callback_fingerprint',
|
||||
'chk_qipai_product_refund_event_from_status',
|
||||
'chk_qipai_product_refund_event_to_status',
|
||||
'chk_qipai_product_refund_event_inventory_status',
|
||||
'chk_qipai_product_refund_event_actor',
|
||||
'chk_qipai_product_refund_event_version'
|
||||
)
|
||||
ORDER BY constraint_name;
|
||||
|
||||
SELECT trigger_name, event_manipulation
|
||||
FROM information_schema.triggers
|
||||
WHERE trigger_schema = DATABASE()
|
||||
AND trigger_name IN (
|
||||
'qipai_product_order_events_no_update',
|
||||
'qipai_product_order_events_no_delete',
|
||||
'qipai_product_order_items_no_update',
|
||||
'qipai_product_order_items_no_delete',
|
||||
'qipai_product_refund_events_no_update',
|
||||
'qipai_product_refund_events_no_delete'
|
||||
)
|
||||
ORDER BY trigger_name;
|
||||
|
||||
SELECT code
|
||||
FROM qipai_permissions
|
||||
WHERE code IN ('goods.order.read', 'goods.order.manage')
|
||||
ORDER BY code;
|
||||
|
||||
SELECT COUNT(*) AS managed_role_count
|
||||
FROM qipai_roles r
|
||||
WHERE r.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND r.status = 'ACTIVE'
|
||||
AND r.deleted_at IS NULL
|
||||
HAVING COUNT(*) = (
|
||||
SELECT COUNT(*)
|
||||
FROM (
|
||||
SELECT granted_role.tenant_id, granted_role.id
|
||||
FROM qipai_roles granted_role
|
||||
INNER JOIN qipai_role_permissions rp
|
||||
ON rp.tenant_id = granted_role.tenant_id AND rp.role_id = granted_role.id
|
||||
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||
WHERE granted_role.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND granted_role.status = 'ACTIVE'
|
||||
AND granted_role.deleted_at IS NULL
|
||||
AND p.code IN ('goods.order.read', 'goods.order.manage')
|
||||
GROUP BY granted_role.tenant_id, granted_role.id
|
||||
HAVING COUNT(DISTINCT p.code) = 2
|
||||
) fully_granted_goods_order_roles
|
||||
);
|
||||
|
||||
SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026081108'
|
||||
AND name = 'm09d2_product_order_payment_inventory'
|
||||
AND checksum REGEXP '^[0-9a-f]{64}$';
|
||||
@@ -0,0 +1,30 @@
|
||||
# 2026-08-11 M09-D2 商品订单 API
|
||||
|
||||
## 顾客接口
|
||||
|
||||
- `POST /app-api/product-orders`
|
||||
- `GET /app-api/product-orders`
|
||||
- `GET /app-api/product-orders/:orderId`
|
||||
- `POST /app-api/product-orders/:orderId/cancel`
|
||||
|
||||
创建请求包含门店、幂等 `requestId`、履约方式、订单备注和 SKU 数量/备注。服务端从当前目录和门店售价生成不可变快照并复算金额,不接受客户端价格。顾客列表、详情和取消只允许订单本人访问。
|
||||
|
||||
## 管理接口
|
||||
|
||||
以下接口同时提供 `/admin-api` 与 `/app-api/management` 前缀:
|
||||
|
||||
- `GET /product-orders?storeId=...`
|
||||
- `GET /product-orders/:orderId`
|
||||
- `POST /product-orders/:orderId/actions`
|
||||
|
||||
动作限定为 `ACCEPT`、`START_DELIVERY`、`MARK_READY`、`COMPLETE`、`CANCEL`、`REFUND`,服务端按履约方式和当前状态执行合法转换。读取要求 `goods.order.read`,写入要求 `goods.order.manage`;STAFF、STORE_ADMIN 必须在授权门店范围内,租户边界在查询和写事务中重复校验。
|
||||
|
||||
## 支付与退款适配边界
|
||||
|
||||
- 非生产且显式启用测试适配器时,注册顾客测试支付创建/完成和管理测试退款完成接口。
|
||||
- 生产环境不注册任何 `test-complete` 路由;真实微信支付/退款适配器调用 `confirmPayment` / `confirmRefund` 领域入口,并提供 provider callbackId、服务端金额和验签后的脱敏 payload。
|
||||
- 回调按 provider/callbackId 和 payload 指纹幂等;金额或 provider 不匹配不推进订单。死锁/锁等待冲突由完整事务有限重试,库存、支付、订单和事件保持原子一致。
|
||||
|
||||
## 状态与错误边界
|
||||
|
||||
订单状态为 `PENDING_PAYMENT`、`PAID`、`ACCEPTED`、`DELIVERING`、`READY_FOR_SELF_SERVICE`、`COMPLETED`、`CANCELLED`、`REFUNDING`、`REFUNDED`、`REFUND_FAILED`。未找到返回 404,权限/门店范围返回 403,版本、状态或幂等冲突返回 409,输入、销售关闭、库存或履约前置失败返回 400 语义错误码。
|
||||
+11
-11
@@ -1,10 +1,10 @@
|
||||
# 当前开发成果基线
|
||||
|
||||
> V5.0 首次核验日期:2026-06-16
|
||||
> audited_commit: `2de7961`
|
||||
> audited_worktree: M09-D1 当前工作树(待本次中文工程提交)
|
||||
> audited_at: `2026-08-11T03:41:30+08:00`
|
||||
> next_engineering_target: 按 V5.7 固定队列执行 M09-D2,建立商品订单与库存锁定、扣减、释放、取消回补闭环
|
||||
> audited_commit: `93af128`
|
||||
> audited_worktree: M09-D2 当前工作树(待本次中文工程提交)
|
||||
> audited_at: `2026-08-11T06:58:52+08:00`
|
||||
> next_engineering_target: 按 V5.7 固定队列执行 M09-D3,建立安全寄存、分次取出、并发防超领和通知 outbox
|
||||
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
|
||||
|
||||
## 总体结论
|
||||
@@ -13,10 +13,10 @@
|
||||
|---|---|---|---|
|
||||
| 总纲版本 | V5.7 已成为当前权威总纲,V5.6 及更早版本仅作历史基线 | 根目录 `V5.7.md` 的增量控制层与第 30 章固定队列 | 必须按 V5.7 游标开发,不得回退到历史队列 |
|
||||
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main` 为 `0 0` | 本地与远端同步 |
|
||||
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 API;M09-A/B/C 已完成保洁收口,M09-D1 已完成商品分类、商品、SKU、门店售卖/营业时段、库存策略、可用/锁定/损耗库存、幂等请求与不可变流水 | `backend/src/products/**`、`backend/src/inventory/**`、`backend/src/routes/{products,inventory}.ts`、`database/migrations/2026081107_m09d1_*`、`backend/tests/product-*.test.mjs`、`backend/tests/inventory-*.test.mjs`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M09-D1 当前工作树的 build、全测、静态门禁和真实 MySQL 门禁通过,游标进入 M09-D2;提交 SHA 待本次中文工程提交取得 |
|
||||
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;12 个业务入口按路由懒加载;保洁工作台覆盖规则、任务、版本验收、同源结算导出、冲正历史、状态事件和人工付款;商品/库存菜单由服务端按 capability 返回 | `admin/src/router.ts`、`admin/src/components/CleaningWorkspace.vue`、`backend/src/routes/admin-auth.ts` | M09-D1 管理端 build/test 通过;商品库存专用运营页面归入后续业务切片,当前进入 M09-D2 |
|
||||
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 API;M09-A/B/C 已完成保洁收口,M09-D1/D2 已完成商品目录、库存、订单项快照、支付/退款衔接和库存占用生命周期 | `backend/src/products/**`、`backend/src/inventory/**`、`backend/src/routes/{products,inventory,product-orders}.ts`、`database/migrations/202608110{7,8}_m09d*`、`backend/tests/product-*.test.mjs`、`backend/tests/inventory-*.test.mjs`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M09-D2 当前工作树的 build、全测、静态门禁和真实 MySQL 门禁通过,游标进入 M09-D3;提交 SHA 待本次中文工程提交取得 |
|
||||
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;12 个业务入口按路由懒加载;保洁工作台覆盖规则、任务、版本验收、同源结算导出、冲正历史、状态事件和人工付款;商品/库存/商品订单菜单能力由服务端 capability 控制 | `admin/src/router.ts`、`admin/src/components/CleaningWorkspace.vue`、`backend/src/routes/admin-auth.ts` | M09-D2 管理 API 与权限边界通过;商品、库存、订单和寄存专用运营页面统一归入 M09-D4 |
|
||||
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路、保洁任务端照片上传/补做主体链路,以及管理员门店/房态/订单/代下单/验券/会员/员工/保洁/设备/经营统计 | `miniapp/pages/index`、`miniapp/pages/store/detail`、`miniapp/pages/room/detail`、`miniapp/pages/orders/*`、`miniapp/pages/recharge/*`、`miniapp/pages/profile/*`、`miniapp/pages/cleaner/*`、`miniapp/pages/manager/*`、`miniapp/utils/api.js`、`scripts/check-miniapp-m08-a.mjs`、`scripts/check-miniapp-m08-c.mjs` | M08-A/B/C/D 可独立工程内容完成,执行游标进入 M09-A;真实团购、微信和硬件现场验收保持外部阻塞,M08 维持 PARTIAL |
|
||||
| 数据库迁移 | 已形成 M01-B 至 M09-D1 连续 up/down/verify 计划;M09-D1 新增 9 张商品/库存表和不可变流水双触发器,迁移 runner 按增量 marker/checksum 执行,并可在 interrupted up/down DDL 后原地重试;同库迁移全流程由专用连接和数据库命名锁串行化 | `database/migrations/2026081107_m09d1_product_inventory_foundation.{up,down,verify}.sql`、`backend/src/db/migration-runner.ts`、`backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 179 up、141 verify、171 down 的 up/verify/down/up/verify;增量跳过、崩溃恢复、双连接迁移互斥、触发器前置、并发单赢家、幂等、负库存拒绝、流水不可变和 cleanup probe 通过 |
|
||||
| 数据库迁移 | 已形成 M01-B 至 M09-D2 连续 up/down/verify 计划;M09-D2 新增 7 张商品订单、订单项/事件、支付、退款/回调表和不可变触发器;M09-D1 崩溃重试会先按依赖撤销 D2,迁移 runner 继续按 marker/checksum 增量执行并由数据库命名锁串行化 | `database/migrations/2026081108_m09d2_product_order_payment_inventory.{up,down,verify}.sql`、`backend/src/db/migration-runner.ts`、`backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 201 up、150 verify、187 down 的 up/verify/down/up/verify;并发支付回调单次扣减、取消/超时释放、退款返库、不可变事件及跨阶段迁移重试通过 |
|
||||
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh`、`scripts/setup/`、`deploy/` | PARTIAL,生产未执行 |
|
||||
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .`、`scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
|
||||
| 参考资料 | 已有清单、脱敏日志、页面/接口/表结构摘要 | `docs/reference-*`、`docs/db-schema-inventory.md` | PARTIAL,仍需按模块迁移正式实现 |
|
||||
@@ -26,7 +26,7 @@
|
||||
## 状态校准
|
||||
|
||||
- `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。
|
||||
- M01-M04 与 M07 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`;M09-A/B/C/D1、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前按 V5.7 执行 M09-D2,M10 尚未进入。
|
||||
- M01-M04 与 M07 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`;M09-A/B/C/D1/D2、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前按 V5.7 执行 M09-D3,M10 尚未进入。
|
||||
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。
|
||||
|
||||
## Git 历史敏感信息核验
|
||||
@@ -37,6 +37,6 @@
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 执行 M09-D2:建立商品订单与订单项金额、商品、SKU、规格、履约方式等服务端快照,明确与支付订单分离的状态机。
|
||||
2. 复用 M09-D1 库存服务,在下单/支付/取消/超时链路中按稳定业务号完成锁定、扣减、释放和取消回补,保证重放幂等与并发单赢家。
|
||||
3. 补商品订单 API、权限、真实 MySQL 事务/崩溃恢复和订单金额服务端复算;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。
|
||||
1. 执行 M09-D3:建立寄存单、寄存明细和数量流水,支持 `STORED/PARTIALLY_RETRIEVED/RETRIEVED/EXPIRED/CANCELLED` 状态机。
|
||||
2. 领取凭证只保存哈希,限制本人或授权门店员工领取;分次取出以事务行锁、稳定 requestId 和剩余数量条件阻止重复、超量与并发超领。
|
||||
3. 寄存、取出、取消和过期同事务写审计与通知 outbox;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 2026-08-11 M09-D2 商品订单、支付与库存生命周期
|
||||
|
||||
- 版本:`2026081108_m09d2_product_order_payment_inventory`。
|
||||
- 新增 7 张表:`qipai_product_orders`、`qipai_product_order_items`、`qipai_product_order_events`、`qipai_product_payments`、`qipai_product_refunds`、`qipai_product_payment_callbacks`、`qipai_product_refund_events`。
|
||||
- 商品订单保存客户端请求指纹、库存业务号/请求号、履约方式、房间订单、金额/数量汇总、库存状态、各阶段时间和版本;数据库 CHECK 保证金额、数量、来源、指纹、版本和房间履约组合合法。
|
||||
- 订单项保存商品/SKU 代码与名称、规格 JSON、单位、单价、数量、小计和备注快照;订单项行号和订单事件请求/版本均唯一,双触发器禁止更新或删除订单项与订单事件。
|
||||
- 支付与商品订单分表;有效支付、支付业务号、客户端请求、provider paymentId 均唯一,退款同样约束有效单据、provider refundId 和返库请求。金额统一整数分。
|
||||
- 支付回调以 provider/callbackId 唯一,保存 payload 指纹、验签结果、处理状态和脱敏 payload;订单/支付/退款/回调复合外键阻止跨租户、跨门店或跨单据串接。
|
||||
- 退款保存 `RESTOCK/NO_RESTOCK` 决策和 `PENDING/SUCCEEDED/FAILED/NOT_REQUIRED` 库存状态;退款事件记录每次状态、库存结果和版本,双触发器禁止修改或删除历史。
|
||||
- 新增 `goods.order.read`、`goods.order.manage` 并授予活动 STAFF、STORE_ADMIN、TENANT_ADMIN、PLATFORM_ADMIN;新租户通过登录和 RBAC 初始化路径取得相同权限,避免迁移后创建的租户缺少授权。
|
||||
- down 先删除不可变触发器、角色权限,再按回调/退款/支付/事件/订单项/订单依赖逆序删除 7 张表和 marker;M09-D1 崩溃重试测试先执行本迁移 down,避免直接删除被 D2 外键引用的库存请求表。
|
||||
- verify 校验 7 张表、列排序规则、22 个唯一/查询索引、18 个复合外键、34 个 CHECK、6 个不可变触发器、2 项权限、完整角色授权和迁移 checksum。
|
||||
- MySQL 8.4.10 已完成 201 up、150 verify、187 down 的 `up → verify → down → up → verify`;并发支付单次扣减、取消/超时释放、退款返库、门店范围、事件不可变与跨阶段重试均通过。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 工程结果
|
||||
|
||||
- 工程状态:当前工作树已完成,待以 `feat(M09-D1): 完成商品目录与库存流水底座` 中文标题提交并取得 SHA。
|
||||
- 工程状态:已由 `93af128002c27ea548e9ed7d0c9af7b8f264002a` 以 `feat(M09-D1): 完成商品目录与库存流水底座` 中文标题提交并推送。
|
||||
- 新增分类、商品、SKU、门店上架、门店销售设置、每周最多 8 段且支持跨日的营业时段;分类/商品/SKU 支持软删除后的代码或条码复用,写操作统一使用 `expectedVersion` 乐观锁。
|
||||
- 新增 `TRACKED` / `UNLIMITED` 库存策略、可用/锁定/损耗三类数量,以及入库、调整、盘点、损耗、批量锁定、释放和扣减服务;批量操作同事务完成,任何一个 SKU 失败均整体回滚。
|
||||
- 库存请求以租户、门店和大小写敏感 requestId 建立全局幂等记录;请求指纹不一致明确拒绝,锁定归属按业务类型和业务号核对,重复释放、跨订单释放和超额扣减不会改动库存。
|
||||
@@ -33,7 +33,7 @@
|
||||
- 管理端 `npm run build`、`npm test`:PASS。
|
||||
- WSL 原生临时副本 + MySQL 8.4.10:完成 `up → verify → down → up → verify`,179 条 up、141 条 verify、171 条 down;双连接迁移锁、interrupted up/down DDL 原地重跑、增量 marker/checksum、触发器生产前置和 cleanup probe 均 PASS。
|
||||
- 真实 MySQL 业务用例覆盖门店唯一性、跨日时段、商品/SKU 版本冲突、并发锁定单赢家、重复 requestId 重放与冲突、批量原子回滚、锁定/释放/扣减、损耗/盘点、负库存拒绝、流水对账与不可变触发器。
|
||||
- Windows 汇总门禁在全部 M09-D1 与 V5.7 文件显式暂存后执行最终复跑;不得将提交前的未跟踪完整性提示记为功能失败。
|
||||
- Windows 汇总门禁在全部 M09-D1 与 V5.7 文件显式暂存后最终复跑通过;提交前的未跟踪完整性提示未被记作功能失败。
|
||||
|
||||
## 阶段结论
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-08-11 M09-D2 商品订单与库存生命周期
|
||||
|
||||
## 工程结果
|
||||
|
||||
- 工程状态:当前工作树已完成,待以 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文标题提交并取得 SHA。
|
||||
- 新增独立的商品订单、订单项快照、订单事件、支付、退款、支付回调和退款事件领域;商品订单不复用房间订单或支付单状态,订单项固化商品/SKU 名称、规格、单价、数量、小计和备注。
|
||||
- 顾客创建订单时由服务端读取有效门店上架、售价、销售开关和跨日营业时段并复算金额;`DELIVERY` 必须绑定本人当前有效房间订单,`SELF_SERVICE` 明确禁止伪造房间绑定。
|
||||
- 下单按稳定订单业务号批量锁定库存;支付成功回调在同一事务扣减并推进 `PAID`,取消和待支付超时释放,退款按履约状态选择 `RESTOCK` 或 `NO_RESTOCK`。重复请求、重复回调和指纹冲突分别重放或明确拒绝。
|
||||
- 支付回调统一事务边界对 MySQL 死锁和锁等待超时最多重试 3 次;真实并发回调验证只有一个扣减副作用,另一请求按已落库 callback 幂等返回。
|
||||
- 顾客 API 支持创建、本人列表/详情、取消;管理 API 同时提供后台和管理员小程序前缀,支持门店列表/详情、接单、配送、自助待取、完成、取消和退款。服务端重复校验 `goods.order.read/manage`、租户和授权门店范围。
|
||||
- 测试支付与退款适配器只在显式非生产开关开启时注册;生产支付/退款回调通过领域服务输入对接后续真实微信适配器,禁止暴露测试完成接口。
|
||||
|
||||
## 数据库结果
|
||||
|
||||
`2026081108_m09d2_product_order_payment_inventory` 新增 7 张表:
|
||||
|
||||
1. `qipai_product_orders`
|
||||
2. `qipai_product_order_items`
|
||||
3. `qipai_product_order_events`
|
||||
4. `qipai_product_payments`
|
||||
5. `qipai_product_refunds`
|
||||
6. `qipai_product_payment_callbacks`
|
||||
7. `qipai_product_refund_events`
|
||||
|
||||
订单项、订单事件和退款事件均由数据库双触发器拒绝 UPDATE/DELETE;订单/支付/退款业务号、客户端请求、有效单据、回调和事件版本使用唯一键防重。复合外键保持租户、门店、订单、支付、退款和库存请求归属一致。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- `node scripts/check-m09-d2-product-orders.mjs`:PASS,静态门禁确认迁移、路由、服务、RBAC、回归入口及 Windows/WSL 门禁均已接线。
|
||||
- 后端 `npm run build`、后端全量 `npm test`:PASS;新增服务与路由测试覆盖服务端快照、状态转换、本人边界、门店范围和测试适配器生产隔离。
|
||||
- WSL 原生临时副本 + MySQL 8.4.10:完成 `up → verify → down → up → verify`,201 条 up、150 条 verify、187 条 down;M09-D1 interrupted up/down 重试会先按依赖撤销 D2,恢复后 D2 权限和表完整重建。
|
||||
- 真实 MySQL 业务用例覆盖下单重放/指纹冲突、无效房间配送拒绝、并发支付单次扣减、配送和自助状态机、已完成订单退款不返库、未履约订单退款返库、待支付取消释放、超时批量回收、跨门店拒绝及不可变事件触发器。
|
||||
- Windows 汇总门禁在全部 M09-D2 与伴随文档显式暂存后执行最终复跑;提交前唯一失败为完整性门禁按设计拒绝未跟踪的新交付文件,不是功能失败。
|
||||
|
||||
## 阶段结论
|
||||
|
||||
`M09-D2=DONE`。唯一执行游标进入 `M09-D3`,下一阶段建立寄存单、分次取出、哈希领取凭证、并发防超领和通知 outbox。
|
||||
@@ -48,7 +48,9 @@
|
||||
|
||||
> 2026-08-11 M09-C:工程提交 `1f10715` 为 `CLN-003`、`CLN-004` 统一协作成员份额的待结算/已结算口径,补活动份额数据库唯一键、并发生成单赢家、成员奖励冻结、不可变负向冲正与状态事件;微信转账在出站前 CAS 为处理中,回调按商户隔离且成功重放幂等,并支持失败后的人工付款。列表/API/CSV 共用查询,所有保洁 CSV 金额输出整数分。Windows 全量与 WSL MySQL 8.4.10 的 160 up、132 verify、154 down 往返通过,游标进入 M09-D1。
|
||||
|
||||
> 2026-08-11 M09-D1:当前工作树为 `PRD-001`、`INV-001` 建立租户/门店隔离的商品目录和库存底座;9 张表覆盖分类、商品、SKU、门店上架、销售设置、跨日时段、库存、幂等请求和不可变流水。增量迁移可跳过历史 marker,interrupted up/down DDL 可原地重试,同库发布由数据库命名锁串行化;触发器生产前置、API/RBAC、并发库存、幂等冲突、负库存拒绝和流水 UPDATE/DELETE 拒绝已通过。WSL MySQL 8.4.10 完成 179 up、141 verify、171 down 往返及 cleanup probe,游标进入 M09-D2;结果随本次中文工程提交固化。
|
||||
> 2026-08-11 M09-D1:工程提交 `93af128` 为 `PRD-001`、`INV-001` 建立租户/门店隔离的商品目录和库存底座;9 张表覆盖分类、商品、SKU、门店上架、销售设置、跨日时段、库存、幂等请求和不可变流水。增量迁移可跳过历史 marker,interrupted up/down DDL 可原地重试,同库发布由数据库命名锁串行化;触发器生产前置、API/RBAC、并发库存、幂等冲突、负库存拒绝和流水 UPDATE/DELETE 拒绝已通过。WSL MySQL 8.4.10 完成 179 up、141 verify、171 down 往返及 cleanup probe,游标进入 M09-D2。
|
||||
|
||||
> 2026-08-11 M09-D2:当前工作树为 `GOODS-102` 建立商品订单与支付库存闭环;7 张表分离商品订单、快照项、状态事件、支付、退款、支付回调和退款事件。下单按服务端目录/价格快照锁库存,支付回调在死锁/锁等待时有限重试并仅扣一次,取消/超时释放锁定,退款按订单履约状态决定返库且重复回调幂等。房间配送校验本人有效房间订单,自助场景不绑定房间;顾客和管理员/员工 API 复用租户、门店范围与 `goods.order.read/manage`。WSL MySQL 8.4.10 完成 201 up、150 verify、187 down 往返,游标进入 M09-D3;结果随本次中文工程提交固化。
|
||||
|
||||
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
@@ -75,8 +77,9 @@
|
||||
| CLN-003 | 保洁统计 | M08-B/M09 | DONE | `1f10715` | 保洁员与管理端统计统一按有效成员份额复算:免清洁不计奖励,DRAFT/CONFIRMED 仍计未付款,只有 PAID 计已结算;负责人/协作者、任务状态、结算状态、门店、日期和每日趋势口径一致。统计页、任务/结算明细和整数分 CSV 可相互校验;真实 MySQL 覆盖协作成员分步付款和最终任务结算。 | - | M10 以生产数据抽样复算,不重开统计状态机。 |
|
||||
| CLN-004 | 保洁结算 | M09-C | PARTIAL | `1f10715` | 同一任务成员份额由数据库生成唯一键保证最多一条有效结算明细,并发生成只有一个赢家;活动结算冻结成员奖励。取消保留原单/明细并生成负向冲正和状态事件,可重新生成;出款前 CAS、稳定请求号、商户隔离回调、成功重放、失败重试、人工已付、同源列表/导出和整数分均已闭环。协作任务按收款人分别付款,全部有效份额 PAID 后任务才进入 SETTLED。 | 真实微信商户凭据、生产小额转账和回调仍为 BLOCKED_EXTERNAL。 | 取得生产商户条件后执行小额转账、迟到回调和账单对账验收;内部工程不阻断 M09-D1。 |
|
||||
| CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 |
|
||||
| PRD-001 | 商品目录与门店售卖 | M09-D1 | DONE | M09-D1 当前工作树(待本次中文工程提交) | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;代码/条码软删除后可复用,写操作用 expectedVersion CAS,跨日营业判断和租户/门店/角色越权拒绝有路由、仓储和真实 MySQL 证据。 | - | M09-D2 复用目录快照创建商品订单,不重建第二套商品主数据。 |
|
||||
| INV-001 | 库存策略与不可变流水 | M09-D1 | DONE | M09-D1 当前工作树(待本次中文工程提交) | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;请求指纹全局幂等、复合外键、数量边界、版本 CAS 和事务保证负库存拒绝与批量原子性,流水版本唯一且双触发器禁止 UPDATE/DELETE。MySQL 8.4.10 覆盖并发单赢家、重放/冲突、锁定归属、对账与 interrupted up/down 恢复。 | - | M09-D2 把订单状态机接入库存锁定、扣减、释放和取消回补。 |
|
||||
| PRD-001 | 商品目录与门店售卖 | M09-D1 | DONE | `93af128` | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;代码/条码软删除后可复用,写操作用 expectedVersion CAS,跨日营业判断和租户/门店/角色越权拒绝有路由、仓储和真实 MySQL 证据。 | - | M09-D4 在顾客端和运营端复用同一目录,不重建第二套商品主数据。 |
|
||||
| INV-001 | 库存策略与不可变流水 | M09-D1/M09-D2 | DONE | `93af128` + M09-D2 当前工作树 | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;M09-D2 已把订单创建、支付、取消、超时和退款接入稳定业务号与同事务库存变更。请求指纹、版本 CAS、数量边界和不可变流水保证并发回调只扣一次、重复释放/返库幂等且不出现负库存。 | - | M09-D3 的寄存数量独立记账,不回写已完成订单项快照。 |
|
||||
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2 | DONE | M09-D2 当前工作树(待本次中文工程提交) | 商品订单与支付单分离;订单项固化商品、SKU、规格、单价、数量和金额快照,状态事件不可变。顾客可下单、查询、取消,授权 STAFF/管理员可接单、配送/待取、完成、取消/退款;房间配送验证本人有效房间订单,自助订单不绑定房间。支付成功扣减、取消/超时释放、退款按履约状态返库均有回调幂等、事务重试、审计和 MySQL 8.4 并发证据。 | - | M09-D3 仅允许从符合条件的已完成商品订单创建寄存来源,并写通知 outbox。 |
|
||||
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 |
|
||||
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 |
|
||||
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
```yaml
|
||||
execution_cursor:
|
||||
current_module: M09
|
||||
current_stage: M09-D2
|
||||
current_stage: M09-D3
|
||||
stage_status: TODO
|
||||
last_completed_stage: M09-D1
|
||||
next_stage: M09-D3
|
||||
last_engineering_commit: `1f10715`
|
||||
pending_engineering_commit: "M09-D1 当前工作树(待本次中文工程提交)"
|
||||
last_completed_stage: M09-D2
|
||||
next_stage: M09-D4
|
||||
last_engineering_commit: `93af128`
|
||||
pending_engineering_commit: "M09-D2 当前工作树(待本次中文工程提交)"
|
||||
last_push_verified: true
|
||||
base_branch: main
|
||||
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNAL;M08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
||||
updated_at: 2026-08-11T03:41:30+08:00
|
||||
updated_at: 2026-08-11T06:58:52+08:00
|
||||
```
|
||||
|
||||
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
|
||||
@@ -26,6 +26,6 @@ execution_cursor:
|
||||
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `da5751f` | docs/devlogs/2026-06-24-M06-E-智慧插座.md | M06-C/E-R1 已按原件校正控制箱、Sub-1G 与智慧插座 Wire/事件/脱敏;M06-R 已通过 Windows 后端全量、MQTT fixture、四项静态检查的中文/空格路径回归及 WSL MySQL 8.4.10 完整往返。M06-G 仍缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
|
||||
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
|
||||
| M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 |
|
||||
| M09 保洁任务、结算、商品和库存 | DOING | M09-D1 当前工作树(待本次中文工程提交) | docs/devlogs/2026-08-11-M09-D1-商品目录与库存底座.md | M09-A/B/C 已完成保洁收口;M09-D1 已完成 9 张表、商品/SKU/门店售卖与跨日时段、库存策略、幂等请求、不可变流水、API/RBAC、增量/崩溃重试迁移和真实 MySQL 门禁。当前进入 M09-D2,建设商品订单与库存占用生命周期。 |
|
||||
| M09 保洁任务、结算、商品和库存 | DOING | M09-D2 当前工作树(待本次中文工程提交) | docs/devlogs/2026-08-11-M09-D2-商品订单与库存生命周期.md | M09-A/B/C 已完成保洁收口;M09-D1 完成商品/库存底座,M09-D2 完成 7 张订单/支付/退款表、服务端快照、订单状态机、支付库存扣减、取消/超时释放、退款返库、顾客与管理 API、RBAC 和真实 MySQL 并发回归。当前进入 M09-D3,建设寄存/取出安全闭环。 |
|
||||
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
|
||||
for (const suffix of ['up', 'down', 'verify']) {
|
||||
assert.ok(
|
||||
existsSync(join(root,
|
||||
`database/migrations/2026081108_m09d2_product_order_payment_inventory.${suffix}.sql`)),
|
||||
`M09-D2 ${suffix} migration is missing`
|
||||
);
|
||||
}
|
||||
|
||||
const migration = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql'
|
||||
);
|
||||
for (const pattern of [
|
||||
'qipai_product_orders',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_refund_events',
|
||||
'PENDING_PAYMENT',
|
||||
'READY_FOR_SELF_SERVICE',
|
||||
'COMPENSATION_REQUIRED',
|
||||
'inventory_request_id',
|
||||
'request_fingerprint',
|
||||
'PRODUCT_ORDER_ITEM_IMMUTABLE',
|
||||
'PRODUCT_ORDER_EVENT_IMMUTABLE',
|
||||
'PRODUCT_REFUND_EVENT_IMMUTABLE',
|
||||
'goods.order.read',
|
||||
'goods.order.manage'
|
||||
]) assert.ok(migration.includes(pattern), `M09-D2 migration is missing ${pattern}`);
|
||||
assert.doesNotMatch(migration, /\bDECIMAL\b/i);
|
||||
assert.match(migration, /subtotal_cents = unit_price_cents \* quantity/);
|
||||
assert.match(migration, /fulfillment_mode = 'DELIVERY' AND room_order_id IS NOT NULL/);
|
||||
assert.match(migration, /fulfillment_mode = 'SELF_SERVICE' AND room_order_id IS NULL/);
|
||||
|
||||
const service = read('backend/src/products/product-order-service.ts');
|
||||
for (const pattern of [
|
||||
'ProductOrderService',
|
||||
'createPaymentForCustomer',
|
||||
'confirmPayment',
|
||||
'confirmRefund',
|
||||
'expirePendingOrders',
|
||||
'resolveProductOrderTransition',
|
||||
'lockMany',
|
||||
'deductMany',
|
||||
'releaseMany',
|
||||
'returnMany',
|
||||
'FOR UPDATE',
|
||||
'PRODUCT_ORDER_IDEMPOTENCY_CONFLICT',
|
||||
'PRODUCT_PAYMENT_CALLBACK_CONFLICT',
|
||||
'PRODUCT_REFUND_CALLBACK_CONFLICT'
|
||||
]) assert.ok(service.includes(pattern), `product order service is missing ${pattern}`);
|
||||
assert.match(service, /beginTransaction\(\)[\s\S]*commit\(\)[\s\S]*rollback\(\)/);
|
||||
assert.match(service, /order\.expiresAt <= this\.now\(\)/);
|
||||
|
||||
const routes = read('backend/src/routes/product-orders.ts');
|
||||
for (const pattern of [
|
||||
'/app-api/product-orders',
|
||||
'/admin-api',
|
||||
'/app-api/management',
|
||||
'goods.order.read',
|
||||
'goods.order.manage',
|
||||
'testAdapterEnabled',
|
||||
'completeTestRefundForManagement'
|
||||
]) assert.ok(routes.includes(pattern), `product order routes are missing ${pattern}`);
|
||||
|
||||
const app = read('backend/src/app.ts');
|
||||
const server = read('backend/src/server.ts');
|
||||
assert.match(app, /registerProductOrderRoutes/);
|
||||
assert.match(app, /productOrders\?: ProductOrderRouteOptions/);
|
||||
assert.match(server, /new ProductOrderService\(pool, inventoryService\)/);
|
||||
assert.match(server, /testAdapterEnabled: config\.payment\.testAdapterEnabled/);
|
||||
|
||||
const rbac = read('backend/src/auth/rbac-repository.ts');
|
||||
assert.match(rbac, /'goods\.order\.read', 'goods\.order\.manage'/);
|
||||
const packageJson = read('backend/package.json');
|
||||
assert.match(packageJson, /product-order-service\.test\.mjs/);
|
||||
assert.match(packageJson, /product-order-route\.test\.mjs/);
|
||||
|
||||
const mysqlTest = read('backend/tests/mysql-migration-roundtrip.test.mjs');
|
||||
for (const pattern of [
|
||||
'assertProductOrderPaymentInventory',
|
||||
'concurrent payment callbacks must deduct inventory once',
|
||||
'PRODUCT_ORDER_ITEM_IMMUTABLE',
|
||||
'PRODUCT_ORDER_EVENT_IMMUTABLE',
|
||||
'PRODUCT_REFUND_EVENT_IMMUTABLE',
|
||||
'expirePendingOrders'
|
||||
]) assert.ok(mysqlTest.includes(pattern), `live MySQL test is missing ${pattern}`);
|
||||
|
||||
console.log(
|
||||
'PASS: M09-D2 product order, payment callback, inventory compensation and immutable-event gates are present.'
|
||||
);
|
||||
@@ -40,6 +40,8 @@ node scripts/check-m09-c-settlement-integrity.mjs
|
||||
Assert-NativeSuccess "check-m09-c-settlement-integrity"
|
||||
node scripts/check-m09-d1-product-inventory.mjs
|
||||
Assert-NativeSuccess "check-m09-d1-product-inventory"
|
||||
node scripts/check-m09-d2-product-orders.mjs
|
||||
Assert-NativeSuccess "check-m09-d2-product-orders"
|
||||
|
||||
if (Test-Path "admin/package.json") {
|
||||
Push-Location admin
|
||||
|
||||
@@ -107,6 +107,6 @@ export QIPAI_MYSQL_USER="${username}"
|
||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
||||
|
||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-D1 integration roundtrip in a temporary database."
|
||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-D2 integration roundtrip in a temporary database."
|
||||
npm --prefix backend run test:mysql:migration
|
||||
echo "PASS: M01-B through M09-D1 live MySQL integration roundtrip completed."
|
||||
echo "PASS: M01-B through M09-D2 live MySQL integration roundtrip completed."
|
||||
|
||||
Reference in New Issue
Block a user