From 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 07:04:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(M09-D2):=20=E5=AE=8C=E6=88=90=E5=95=86?= =?UTF-8?q?=E5=93=81=E8=AE=A2=E5=8D=95=E4=B8=8E=E5=BA=93=E5=AD=98=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- V5.7.md | 62 +- backend/package.json | 2 +- backend/src/app.ts | 8 + backend/src/auth/auth-repository.ts | 21 +- backend/src/auth/rbac-repository.ts | 5 +- backend/src/db/migration-runner.ts | 10 +- backend/src/inventory/inventory-service.ts | 77 +- backend/src/products/product-order-service.ts | 1510 +++++++++++++++++ backend/src/routes/product-orders.ts | 295 ++++ backend/src/server.ts | 9 + backend/tests/inventory-service.test.mjs | 67 +- backend/tests/migration-contract.test.mjs | 75 +- backend/tests/migration-runner.test.mjs | 16 +- .../tests/mysql-migration-roundtrip.test.mjs | 333 +++- backend/tests/product-order-route.test.mjs | 174 ++ backend/tests/product-order-service.test.mjs | 279 +++ ...2_product_order_payment_inventory.down.sql | 23 + ...9d2_product_order_payment_inventory.up.sql | 459 +++++ ...product_order_payment_inventory.verify.sql | 197 +++ .../2026-08-11-M09-D2-product-orders.md | 30 + docs/current-baseline.md | 22 +- ...-M09-D2-product-order-payment-inventory.md | 13 + .../2026-08-11-M09-D1-商品目录与库存底座.md | 4 +- ...026-08-11-M09-D2-商品订单与库存生命周期.md | 37 + docs/feature-status.md | 9 +- docs/module-status.md | 14 +- scripts/check-m09-d2-product-orders.mjs | 99 ++ scripts/dev/windows/test-all.ps1 | 2 + scripts/dev/wsl/mysql-migration-roundtrip.sh | 4 +- 30 files changed, 3773 insertions(+), 91 deletions(-) create mode 100644 backend/src/products/product-order-service.ts create mode 100644 backend/src/routes/product-orders.ts create mode 100644 backend/tests/product-order-route.test.mjs create mode 100644 backend/tests/product-order-service.test.mjs create mode 100644 database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql create mode 100644 database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql create mode 100644 database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql create mode 100644 docs/api-changelog/2026-08-11-M09-D2-product-orders.md create mode 100644 docs/db-changelog/2026-08-11-M09-D2-product-order-payment-inventory.md create mode 100644 docs/devlogs/2026-08-11-M09-D2-商品订单与库存生命周期.md create mode 100644 scripts/check-m09-d2-product-orders.mjs diff --git a/README.md b/README.md index a2bf763..4b476c4 100644 --- a/README.md +++ b/README.md @@ -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 入口 diff --git a/V5.7.md b/V5.7.md index 8dbb522..fe90d19 100644 --- a/V5.7.md +++ b/V5.7.md @@ -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,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。 diff --git a/backend/package.json b/backend/package.json index a8eec89..437af8a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.ts b/backend/src/app.ts index 8d30dd2..f29e5f4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 = { '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 = { '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( diff --git a/backend/src/inventory/inventory-service.ts b/backend/src/inventory/inventory-service.ts index 9fe873c..9e28a62 100644 --- a/backend/src/inventory/inventory-service.ts +++ b/backend/src/inventory/inventory-service.ts @@ -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( + `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) { diff --git a/backend/src/products/product-order-service.ts b/backend/src/products/product-order-service.ts new file mode 100644 index 0000000..db5519d --- /dev/null +++ b/backend/src/products/product-order-service.ts @@ -0,0 +1,1510 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import type { MySqlPool } from '../db/mysql.js'; +import { + InventoryError, + type InventoryBatchItem, + type InventoryService +} from '../inventory/inventory-service.js'; +import { + isProductStoreOpenAt, + ProductCatalogError, + type ProductBusinessHourInput +} from './product-catalog-repository.js'; + +export const productOrderStatuses = [ + 'PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING', + 'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED', + 'REFUNDING', 'REFUNDED', 'REFUND_FAILED' +] as const; +export type ProductOrderStatus = typeof productOrderStatuses[number]; + +export const productOrderManagementActions = [ + 'ACCEPT', 'START_DELIVERY', 'MARK_READY', 'COMPLETE', 'CANCEL', 'REFUND' +] as const; +export type ProductOrderManagementAction = typeof productOrderManagementActions[number]; + +export interface ProductOrderActor { + tenantId: string; + userId: string; + access: AccessProfile; + source: 'CUSTOMER' | 'MANAGEMENT'; + traceId: string; + ip: string; + userAgent: string; +} + +export interface ProductOrderCreateInput { + storeId: string; + requestId: string; + fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE'; + roomOrderId?: string | null; + note: string; + items: Array<{ skuId: string; quantity: number; note: string }>; +} + +export interface ProductPaymentCreateInput { + requestId: string; + provider: 'TEST' | 'WECHAT'; + channel?: string; +} + +export interface ProductPaymentResultInput { + tenantId: string; + paymentId: string; + provider: 'TEST' | 'WECHAT'; + callbackId: string; + amountCents: number; + payload?: Record; + traceId: string; +} + +export interface ProductRefundResultInput { + tenantId: string; + refundId: string; + provider: 'TEST' | 'WECHAT'; + callbackId: string; + amountCents: number; + payload?: Record; + traceId: string; +} + +interface ProductOrderServiceOptions { + now?: () => Date; + paymentHoldMinutes?: number; +} + +interface CountRow extends RowDataPacket { total: number } +interface IdRow extends RowDataPacket { id: string } +interface RequestEventRow extends RowDataPacket { + orderId: string; + action: string; +} +interface CallbackRow extends RowDataPacket { + paymentId: string; + refundId: string | null; + payloadFingerprint: string; + processingStatus: string; +} +interface StoreSettingsRow extends RowDataPacket { + id: string | null; + timezone: string; + businessStatus: string; + salesStatus: 'OPEN' | 'CLOSED' | null; + manualPausedAt: Date | null; + manualPausedUntil: Date | null; +} +interface BusinessHourRow extends RowDataPacket { + weekday: number; + slotNo: number; + openMinute: number; + closeMinute: number; + crossesMidnight: number; +} +interface SkuSnapshotRow extends RowDataPacket { + skuId: string; + productId: string; + skuCode: string; + skuName: string; + attributesSnapshot: unknown; + unitPriceCents: number; + productCode: string; + productName: string; + unitName: string; + deliveryEnabled: number; + fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE' | 'BOTH'; + salesStartAt: Date | null; + salesEndAt: Date | null; +} +interface OrderRow extends RowDataPacket { + id: string; + tenantId: string; + storeId: string; + memberId: string | null; + roomOrderId: string | null; + orderNo: string; + clientRequestId: string; + requestFingerprint: string; + inventoryBusinessId: string; + inventoryRequestId: string; + fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE'; + status: ProductOrderStatus; + itemCount: number; + totalQuantity: number; + totalAmountCents: number; + paidAmountCents: number; + refundedAmountCents: number; + orderNote: string; + inventoryStatus: 'LOCKED' | 'DEDUCTED' | 'RELEASED' | 'RETURNED'; + expiresAt: Date; + paidAt: Date | null; + acceptedAt: Date | null; + deliveringAt: Date | null; + completedAt: Date | null; + cancelledAt: Date | null; + createdSource: 'MEMBER' | 'STAFF' | 'SYSTEM'; + createdBy: string | null; + version: number; + createdAt: Date; + updatedAt: Date; +} +interface PaymentRow extends RowDataPacket { + id: string; + tenantId: string; + storeId: string; + orderId: string; + paymentNo: string; + clientRequestId: string; + requestFingerprint: string; + provider: 'TEST' | 'WECHAT'; + channel: string; + status: string; + amountCents: number; + refundedAmountCents: number; + expiresAt: Date; + paidAt: Date | null; + version: number; +} +interface RefundRow extends RowDataPacket { + id: string; + tenantId: string; + storeId: string; + orderId: string; + paymentId: string; + refundNo: string; + clientRequestId: string; + requestFingerprint: string; + provider: 'TEST' | 'WECHAT'; + status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED'; + amountCents: number; + inventoryDisposition: 'RESTOCK' | 'NO_RESTOCK'; + inventoryStatus: 'PENDING' | 'SUCCEEDED' | 'FAILED' | 'NOT_REQUIRED'; + inventoryRequestId: string | null; + version: number; +} + +const idPattern = /^[1-9]\d{0,19}$/; +const requestIdPattern = /^[A-Za-z0-9._:-]{1,128}$/; +const inventoryRequestIdPattern = /^[A-Za-z0-9._:-]{1,64}$/; +const MAX_TOTAL_CENTS = 100_000_000_000_000; +const ORDER_COLUMNS = ` + o.id, o.tenant_id AS tenantId, o.store_id AS storeId, + o.member_id AS memberId, o.room_order_id AS roomOrderId, + o.order_no AS orderNo, o.client_request_id AS clientRequestId, + o.request_fingerprint AS requestFingerprint, + o.inventory_business_id AS inventoryBusinessId, + o.inventory_request_id AS inventoryRequestId, + o.fulfillment_mode AS fulfillmentMode, o.status, + o.item_count AS itemCount, o.total_quantity AS totalQuantity, + o.total_amount_cents AS totalAmountCents, + o.paid_amount_cents AS paidAmountCents, + o.refunded_amount_cents AS refundedAmountCents, + o.order_note AS orderNote, o.inventory_status AS inventoryStatus, + o.expires_at AS expiresAt, o.paid_at AS paidAt, + o.accepted_at AS acceptedAt, o.delivering_at AS deliveringAt, + o.completed_at AS completedAt, o.cancelled_at AS cancelledAt, + o.created_source AS createdSource, o.created_by AS createdBy, + o.version, o.created_at AS createdAt, o.updated_at AS updatedAt`; + +export class ProductOrderError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class ProductOrderService { + private readonly now: () => Date; + private readonly paymentHoldMinutes: number; + + constructor( + private readonly pool: MySqlPool, + private readonly inventory: Pick, + options: ProductOrderServiceOptions = {} + ) { + this.now = options.now ?? (() => new Date()); + this.paymentHoldMinutes = Math.max(5, Math.min(options.paymentHoldMinutes ?? 15, 30)); + } + + async create(actor: ProductOrderActor, input: ProductOrderCreateInput) { + const normalized = normalizeCreateInput(input); + const fingerprint = productOrderFingerprint(normalized); + const existing = await this.findByClientRequest(actor.tenantId, normalized.requestId); + if (existing) return this.resolveCreateReplay(actor, existing, fingerprint); + + try { + return await this.transaction(async (connection) => { + const replay = await this.findByClientRequest( + actor.tenantId, normalized.requestId, connection, true + ); + if (replay) return this.resolveCreateReplay(actor, replay, fingerprint, connection); + + const now = this.now(); + await this.assertStoreOpen(connection, actor.tenantId, normalized.storeId, now); + if (normalized.fulfillmentMode === 'DELIVERY') { + await this.assertActiveRoomOrder( + connection, actor, normalized.storeId, normalized.roomOrderId!, now + ); + } + const snapshots = await this.loadSkuSnapshots( + connection, actor.tenantId, normalized.storeId, + normalized.items.map((item) => item.skuId), normalized.fulfillmentMode, now + ); + const snapshotBySku = new Map(snapshots.map((row) => [String(row.skuId), row])); + if (snapshotBySku.size !== normalized.items.length) { + throw new ProductOrderError('PRODUCT_ORDER_ITEM_UNAVAILABLE'); + } + const totalQuantity = normalized.items.reduce((sum, item) => sum + item.quantity, 0); + const totalAmountCents = normalized.items.reduce((sum, item) => { + const snapshot = snapshotBySku.get(item.skuId)!; + return sum + Number(snapshot.unitPriceCents) * item.quantity; + }, 0); + if (!Number.isSafeInteger(totalAmountCents) + || totalAmountCents < 1 || totalAmountCents > MAX_TOTAL_CENTS) { + throw new ProductOrderError('PRODUCT_ORDER_AMOUNT_INVALID'); + } + + const orderNo = makeNumber('PG'); + const inventoryBusinessId = `PRODUCT_ORDER:${orderNo}`; + const inventoryRequestId = internalRequestId('lock', normalized.requestId); + await this.inventory.lockMany({ + tenantId: actor.tenantId, + storeId: normalized.storeId, + requestId: inventoryRequestId, + businessType: 'PRODUCT_ORDER', + businessId: inventoryBusinessId, + items: normalized.items.map(({ skuId, quantity }) => ({ skuId, quantity })), + traceId: actor.traceId, + operatorId: actor.userId, + ip: actor.ip, + userAgent: actor.userAgent, + reason: '商品订单创建锁定库存', + metadata: { orderNo, clientRequestId: normalized.requestId } + }, connection); + + const expiresAt = new Date(now.getTime() + this.paymentHoldMinutes * 60_000); + const createdSource = actor.source === 'CUSTOMER' ? 'MEMBER' : 'STAFF'; + const [inserted] = await connection.execute( + `INSERT INTO qipai_product_orders + (tenant_id, store_id, member_id, room_order_id, order_no, + client_request_id, request_fingerprint, inventory_business_id, + inventory_request_id, fulfillment_mode, status, item_count, + total_quantity, total_amount_cents, order_note, inventory_status, + expires_at, created_source, created_by, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING_PAYMENT', ?, ?, ?, ?, + 'LOCKED', ?, ?, ?, 1)`, + [actor.tenantId, normalized.storeId, + actor.source === 'CUSTOMER' ? actor.userId : null, + normalized.roomOrderId ?? null, orderNo, normalized.requestId, fingerprint, + inventoryBusinessId, inventoryRequestId, normalized.fulfillmentMode, + normalized.items.length, totalQuantity, totalAmountCents, normalized.note, + expiresAt, createdSource, + actor.source === 'MANAGEMENT' ? actor.userId : null] + ); + const orderId = String(inserted.insertId); + for (let index = 0; index < normalized.items.length; index += 1) { + const item = normalized.items[index]; + const snapshot = snapshotBySku.get(item.skuId)!; + const unitPriceCents = Number(snapshot.unitPriceCents); + await connection.execute( + `INSERT INTO qipai_product_order_items + (tenant_id, store_id, order_id, line_no, product_id, sku_id, + product_code, product_name, sku_code, sku_name, unit_name, + attributes_snapshot, unit_price_cents, quantity, subtotal_cents, item_note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [actor.tenantId, normalized.storeId, orderId, index + 1, + String(snapshot.productId), String(snapshot.skuId), snapshot.productCode, + snapshot.productName, snapshot.skuCode, snapshot.skuName, snapshot.unitName, + jsonOrNull(snapshot.attributesSnapshot), unitPriceCents, item.quantity, + unitPriceCents * item.quantity, item.note] + ); + } + await this.insertOrderEvent(connection, actor, { + orderId, storeId: normalized.storeId, requestId: normalized.requestId, + fromStatus: null, toStatus: 'PENDING_PAYMENT', action: 'CREATE', + inventoryRequestId, versionAfter: 1, reason: '', + metadata: { orderNo, itemCount: normalized.items.length, totalAmountCents } + }); + await this.audit(connection, actor, 'PRODUCT_ORDER_CREATED', 'PRODUCT_ORDER', orderId, { + storeId: normalized.storeId, orderNo, fulfillmentMode: normalized.fulfillmentMode, + totalAmountCents, inventoryRequestId + }); + return this.loadOrderView(connection, actor, orderId, false); + }); + } catch (error) { + if (isDuplicateKey(error)) { + const replay = await this.findByClientRequest(actor.tenantId, normalized.requestId); + if (replay) return this.resolveCreateReplay(actor, replay, fingerprint); + } + throw translateInventoryError(error); + } + } + + async listForCustomer(actor: ProductOrderActor, input: { + storeId?: string; status?: ProductOrderStatus; page: number; pageSize: number; + }) { + const where = ['o.tenant_id = ?', 'o.member_id = ?', 'o.deleted_at IS NULL']; + const params: any[] = [actor.tenantId, actor.userId]; + if (input.storeId) { where.push('o.store_id = ?'); params.push(input.storeId); } + if (input.status) { where.push('o.status = ?'); params.push(input.status); } + return this.list(where, params, input.page, input.pageSize); + } + + async getForCustomer(actor: ProductOrderActor, orderId: string) { + assertId(orderId); + return this.transaction((connection) => this.loadOrderView(connection, actor, orderId, false)); + } + + async cancelForCustomer( + actor: ProductOrderActor, + orderId: string, + input: { requestId: string; reason: string } + ) { + assertId(orderId); assertRequestId(input.requestId); + return this.cancel(actor, orderId, input.requestId, input.reason, false); + } + + async listForManagement(actor: ProductOrderActor, input: { + storeId: string; status?: ProductOrderStatus; page: number; pageSize: number; + }) { + this.assertManagementStore(actor, input.storeId); + const where = ['o.tenant_id = ?', 'o.store_id = ?', 'o.deleted_at IS NULL']; + const params: any[] = [actor.tenantId, input.storeId]; + if (input.status) { where.push('o.status = ?'); params.push(input.status); } + return this.list(where, params, input.page, input.pageSize); + } + + async getForManagement(actor: ProductOrderActor, orderId: string) { + assertId(orderId); + return this.transaction((connection) => this.loadOrderView(connection, actor, orderId, true)); + } + + async managementAction( + actor: ProductOrderActor, + orderId: string, + input: { requestId: string; action: ProductOrderManagementAction; reason: string } + ) { + assertId(orderId); assertRequestId(input.requestId); + if (input.action === 'CANCEL') { + return this.cancel(actor, orderId, input.requestId, input.reason, true); + } + return this.transaction(async (connection) => { + const replay = await this.findOrderEvent(connection, actor.tenantId, input.requestId); + if (replay) { + if (String(replay.orderId) !== orderId || replay.action !== input.action) { + throw new ProductOrderError('PRODUCT_ORDER_IDEMPOTENCY_CONFLICT'); + } + return this.loadOrderView(connection, actor, orderId, true); + } + const order = await this.loadOrder(connection, actor.tenantId, orderId, true); + this.assertManagementStore(actor, order.storeId); + if (input.action === 'REFUND') { + await this.beginRefund(connection, actor, order, input.requestId, input.reason); + return this.loadOrderView(connection, actor, orderId, true); + } + const target = resolveProductOrderTransition( + order.status, order.fulfillmentMode, input.action + ); + const version = Number(order.version) + 1; + const timestampColumn = target === 'ACCEPTED' ? 'accepted_at' + : target === 'DELIVERING' || target === 'READY_FOR_SELF_SERVICE' ? 'delivering_at' + : target === 'COMPLETED' ? 'completed_at' : null; + const timestampSql = timestampColumn ? `, ${timestampColumn} = UTC_TIMESTAMP(3)` : ''; + await connection.execute( + `UPDATE qipai_product_orders + SET status = ?, version = ?${timestampSql} + WHERE tenant_id = ? AND id = ? AND version = ?`, + [target, version, actor.tenantId, orderId, order.version] + ); + await this.insertOrderEvent(connection, actor, { + orderId, storeId: order.storeId, requestId: input.requestId, + fromStatus: order.status, toStatus: target, action: input.action, + inventoryRequestId: null, versionAfter: version, reason: input.reason, + metadata: { fulfillmentMode: order.fulfillmentMode } + }); + await this.audit(connection, actor, `PRODUCT_ORDER_${input.action}`, + 'PRODUCT_ORDER', orderId, { fromStatus: order.status, toStatus: target }); + return this.loadOrderView(connection, actor, orderId, true); + }); + } + + async createPaymentForCustomer( + actor: ProductOrderActor, + orderId: string, + input: ProductPaymentCreateInput + ) { + assertId(orderId); assertRequestId(input.requestId); + const fingerprint = hashJson({ orderId, provider: input.provider, channel: input.channel ?? input.provider }); + return this.transaction(async (connection) => { + const [replays] = await connection.execute( + `SELECT id, tenant_id AS tenantId, store_id AS storeId, order_id AS orderId, + payment_no AS paymentNo, client_request_id AS clientRequestId, + request_fingerprint AS requestFingerprint, provider, channel, status, + amount_cents AS amountCents, refunded_amount_cents AS refundedAmountCents, + expires_at AS expiresAt, paid_at AS paidAt, version + FROM qipai_product_payments + WHERE tenant_id = ? AND client_request_id = ? FOR UPDATE`, + [actor.tenantId, input.requestId] + ); + const replay = replays[0]; + if (replay) { + if (String(replay.orderId) !== orderId || replay.requestFingerprint !== fingerprint) { + throw new ProductOrderError('PRODUCT_PAYMENT_IDEMPOTENCY_CONFLICT'); + } + await this.loadOrderOwnedByCustomer(connection, actor, orderId, true); + return publicPayment(replay, true); + } + const order = await this.loadOrderOwnedByCustomer(connection, actor, orderId, true); + if (order.status !== 'PENDING_PAYMENT') { + throw new ProductOrderError('PRODUCT_PAYMENT_NOT_ALLOWED'); + } + if (order.expiresAt <= this.now()) throw new ProductOrderError('PRODUCT_ORDER_EXPIRED'); + const [active] = await connection.execute( + `SELECT id, tenant_id AS tenantId, store_id AS storeId, order_id AS orderId, + payment_no AS paymentNo, client_request_id AS clientRequestId, + request_fingerprint AS requestFingerprint, provider, channel, status, + amount_cents AS amountCents, refunded_amount_cents AS refundedAmountCents, + expires_at AS expiresAt, paid_at AS paidAt, version + FROM qipai_product_payments + WHERE tenant_id = ? AND store_id = ? AND order_id = ? + AND status IN ('PENDING', 'PROCESSING') FOR UPDATE`, + [actor.tenantId, order.storeId, orderId] + ); + if (active[0]) throw new ProductOrderError('PRODUCT_PAYMENT_IN_PROGRESS'); + const paymentNo = makeNumber('PP'); + const [inserted] = await connection.execute( + `INSERT INTO qipai_product_payments + (tenant_id, store_id, order_id, payment_no, client_request_id, + request_fingerprint, provider, channel, status, amount_cents, + currency, expires_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, 'CNY', ?, 1)`, + [actor.tenantId, order.storeId, orderId, paymentNo, input.requestId, + fingerprint, input.provider, input.channel ?? input.provider, + order.totalAmountCents, order.expiresAt] + ); + await this.audit(connection, actor, 'PRODUCT_PAYMENT_CREATED', + 'PRODUCT_PAYMENT', String(inserted.insertId), { + orderId, paymentNo, provider: input.provider, amountCents: order.totalAmountCents + }); + return { + id: String(inserted.insertId), orderId, paymentNo, provider: input.provider, + channel: input.channel ?? input.provider, status: 'PENDING', + amountCents: Number(order.totalAmountCents), expiresAt: order.expiresAt, + idempotent: false + }; + }); + } + + async completeTestPaymentForCustomer(actor: ProductOrderActor, paymentId: string, input: { + callbackId: string; amountCents: number; + }) { + if (!inventoryRequestIdPattern.test(input.callbackId)) { + throw new ProductOrderError('PRODUCT_PAYMENT_CALLBACK_INVALID'); + } + assertId(paymentId); + const [rows] = await this.pool.execute<(PaymentRow & { memberId: string | null })[]>( + `SELECT p.id, p.tenant_id AS tenantId, p.store_id AS storeId, + p.order_id AS orderId, p.provider, o.member_id AS memberId + FROM qipai_product_payments p + INNER JOIN qipai_product_orders o + ON o.tenant_id = p.tenant_id AND o.store_id = p.store_id AND o.id = p.order_id + WHERE p.tenant_id = ? AND p.id = ? AND o.deleted_at IS NULL`, + [actor.tenantId, paymentId] + ); + const payment = rows[0]; + if (!payment) throw new ProductOrderError('PRODUCT_PAYMENT_NOT_FOUND'); + if (String(payment.memberId) !== actor.userId) { + throw new ProductOrderError('PRODUCT_ORDER_OPERATION_FORBIDDEN'); + } + if (payment.provider !== 'TEST') throw new ProductOrderError('PRODUCT_PAYMENT_TEST_ONLY'); + return this.confirmPayment({ + tenantId: actor.tenantId, paymentId, provider: 'TEST', + callbackId: input.callbackId, amountCents: input.amountCents, + payload: { adapter: 'TEST' }, traceId: actor.traceId + }); + } + + async confirmPayment(input: ProductPaymentResultInput) { + assertId(input.paymentId); assertRequestId(input.callbackId); + const result = await this.transaction(async (connection) => { + const fingerprint = hashJson({ + paymentId: input.paymentId, amountCents: input.amountCents, payload: input.payload ?? {} + }); + const callbackReplay = await this.findCallback( + connection, input.tenantId, input.provider, input.callbackId + ); + if (callbackReplay) { + if (String(callbackReplay.paymentId) !== input.paymentId + || callbackReplay.refundId !== null + || callbackReplay.payloadFingerprint !== fingerprint) { + throw new ProductOrderError('PRODUCT_PAYMENT_CALLBACK_CONFLICT'); + } + return { paymentId: input.paymentId, idempotent: true }; + } + const payment = await this.loadPayment(connection, input.tenantId, input.paymentId, true); + if (payment.provider !== input.provider) { + throw new ProductOrderError('PRODUCT_PAYMENT_PROVIDER_MISMATCH'); + } + const order = await this.loadOrder(connection, input.tenantId, payment.orderId, true); + const [callbackInsert] = await connection.execute( + `INSERT INTO qipai_product_payment_callbacks + (tenant_id, store_id, order_id, payment_id, refund_id, provider, + callback_id, callback_type, payload_fingerprint, verified, + processing_status, payload) + VALUES (?, ?, ?, ?, NULL, ?, ?, 'PAYMENT', ?, 1, 'RECEIVED', ?)`, + [input.tenantId, payment.storeId, payment.orderId, payment.id, + input.provider, input.callbackId, fingerprint, JSON.stringify(input.payload ?? {})] + ); + const callbackId = String(callbackInsert.insertId); + if (Number(payment.amountCents) !== input.amountCents) { + await connection.execute( + `UPDATE qipai_product_payment_callbacks + SET processing_status = 'FAILED', error_code = 'AMOUNT_MISMATCH', + processed_at = UTC_TIMESTAMP(3) WHERE id = ?`, [callbackId] + ); + await connection.execute( + `UPDATE qipai_product_payments + SET status = 'FAILED', failed_at = UTC_TIMESTAMP(3), + failure_code = 'AMOUNT_MISMATCH', version = version + 1 + WHERE tenant_id = ? AND id = ? AND status IN ('PENDING', 'PROCESSING')`, + [input.tenantId, payment.id] + ); + return { error: 'PRODUCT_PAYMENT_AMOUNT_MISMATCH' } as const; + } + if (payment.status === 'SUCCEEDED' || payment.status === 'REFUNDED') { + await this.finishCallback(connection, callbackId, 'IGNORED', 'ALREADY_SUCCEEDED'); + return { paymentId: payment.id, orderId: order.id, status: payment.status, idempotent: true }; + } + if (payment.status !== 'PENDING' && payment.status !== 'PROCESSING') { + await this.finishCallback(connection, callbackId, 'IGNORED', 'PAYMENT_NOT_PENDING'); + return { error: 'PRODUCT_PAYMENT_NOT_ALLOWED' } as const; + } + if (order.status !== 'PENDING_PAYMENT' || order.inventoryStatus !== 'LOCKED') { + await connection.execute( + `UPDATE qipai_product_payments + SET status = 'COMPENSATION_REQUIRED', paid_at = UTC_TIMESTAMP(3), + version = version + 1 WHERE tenant_id = ? AND id = ?`, + [input.tenantId, payment.id] + ); + await this.finishCallback(connection, callbackId, 'PROCESSED', 'COMPENSATION_REQUIRED'); + return { error: 'PRODUCT_PAYMENT_COMPENSATION_REQUIRED' } as const; + } + if (order.expiresAt <= this.now()) { + await this.releasePendingInventory(connection, systemActor(input), order, '支付回调到达时订单已超时'); + await connection.execute( + `UPDATE qipai_product_payments + SET status = 'COMPENSATION_REQUIRED', paid_at = UTC_TIMESTAMP(3), + version = version + 1 WHERE tenant_id = ? AND id = ?`, + [input.tenantId, payment.id] + ); + await this.finishCallback(connection, callbackId, 'PROCESSED', 'COMPENSATION_REQUIRED'); + return { error: 'PRODUCT_PAYMENT_COMPENSATION_REQUIRED' } as const; + } + const items = await this.loadInventoryItems(connection, input.tenantId, order.id); + const deductRequestId = internalRequestId('deduct', order.clientRequestId); + await this.inventory.deductMany({ + tenantId: input.tenantId, storeId: order.storeId, items, + requestId: deductRequestId, businessType: 'PRODUCT_ORDER', + businessId: order.inventoryBusinessId, traceId: input.traceId, + operatorId: null, reason: '商品订单支付成功扣减库存', + metadata: { paymentId: payment.id, callbackId: input.callbackId } + }, connection); + const version = Number(order.version) + 1; + await connection.execute( + `UPDATE qipai_product_payments + SET status = 'SUCCEEDED', paid_at = UTC_TIMESTAMP(3), + version = version + 1, failure_code = '' + WHERE tenant_id = ? AND id = ?`, [input.tenantId, payment.id] + ); + await connection.execute( + `UPDATE qipai_product_orders + SET status = 'PAID', inventory_status = 'DEDUCTED', + paid_amount_cents = total_amount_cents, paid_at = UTC_TIMESTAMP(3), version = ? + WHERE tenant_id = ? AND id = ?`, [version, input.tenantId, order.id] + ); + await this.insertOrderEvent(connection, systemActor(input), { + orderId: order.id, storeId: order.storeId, + requestId: internalEventRequestId('payment-callback', input.callbackId), + fromStatus: 'PENDING_PAYMENT', toStatus: 'PAID', action: 'PAYMENT_SUCCEEDED', + inventoryRequestId: deductRequestId, versionAfter: version, reason: '', + metadata: { paymentId: payment.id, provider: payment.provider } + }, 'CALLBACK'); + await this.finishCallback(connection, callbackId, 'PROCESSED', ''); + return { paymentId: payment.id, orderId: order.id, status: 'SUCCEEDED', idempotent: false }; + }); + if ('error' in result) throw new ProductOrderError(String(result.error)); + return result; + } + + async completeTestRefundForManagement(actor: ProductOrderActor, refundId: string, input: { + callbackId: string; amountCents: number; + }) { + assertId(refundId); assertRequestId(input.callbackId); + const [rows] = await this.pool.execute( + `SELECT id, tenant_id AS tenantId, store_id AS storeId, order_id AS orderId, + payment_id AS paymentId, provider + FROM qipai_product_refunds WHERE tenant_id = ? AND id = ?`, + [actor.tenantId, refundId] + ); + const refund = rows[0]; + if (!refund) throw new ProductOrderError('PRODUCT_REFUND_NOT_FOUND'); + this.assertManagementStore(actor, refund.storeId); + if (refund.provider !== 'TEST') throw new ProductOrderError('PRODUCT_REFUND_TEST_ONLY'); + return this.confirmRefund({ + tenantId: actor.tenantId, refundId, provider: 'TEST', callbackId: input.callbackId, + amountCents: input.amountCents, payload: { adapter: 'TEST' }, traceId: actor.traceId + }); + } + + async confirmRefund(input: ProductRefundResultInput) { + assertId(input.refundId); assertRequestId(input.callbackId); + const result = await this.transaction(async (connection) => { + const refund = await this.loadRefund(connection, input.tenantId, input.refundId, true); + const payment = await this.loadPayment(connection, input.tenantId, refund.paymentId, true); + const order = await this.loadOrder(connection, input.tenantId, refund.orderId, true); + if (refund.provider !== input.provider) { + throw new ProductOrderError('PRODUCT_REFUND_PROVIDER_MISMATCH'); + } + const fingerprint = hashJson({ + refundId: input.refundId, amountCents: input.amountCents, payload: input.payload ?? {} + }); + const replay = await this.findCallback(connection, input.tenantId, input.provider, input.callbackId); + if (replay) { + if (String(replay.refundId) !== input.refundId + || replay.payloadFingerprint !== fingerprint) { + throw new ProductOrderError('PRODUCT_REFUND_CALLBACK_CONFLICT'); + } + return { refundId: input.refundId, orderId: order.id, idempotent: true }; + } + const [callbackInsert] = await connection.execute( + `INSERT INTO qipai_product_payment_callbacks + (tenant_id, store_id, order_id, payment_id, refund_id, provider, + callback_id, callback_type, payload_fingerprint, verified, + processing_status, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, 'REFUND', ?, 1, 'RECEIVED', ?)`, + [input.tenantId, order.storeId, order.id, payment.id, refund.id, + input.provider, input.callbackId, fingerprint, JSON.stringify(input.payload ?? {})] + ); + const callbackId = String(callbackInsert.insertId); + if (Number(refund.amountCents) !== input.amountCents) { + await this.failRefund(connection, refund, order, callbackId, 'AMOUNT_MISMATCH', input); + return { error: 'PRODUCT_REFUND_AMOUNT_MISMATCH' } as const; + } + if (refund.status === 'SUCCEEDED') { + await this.finishCallback(connection, callbackId, 'IGNORED', 'ALREADY_SUCCEEDED'); + return { refundId: refund.id, orderId: order.id, idempotent: true }; + } + if (refund.status !== 'PENDING' && refund.status !== 'PROCESSING') { + await this.finishCallback(connection, callbackId, 'IGNORED', 'REFUND_NOT_PENDING'); + return { error: 'PRODUCT_REFUND_NOT_ALLOWED' } as const; + } + let inventoryRequestId: string | null = null; + let inventoryStatus: 'SUCCEEDED' | 'NOT_REQUIRED' = 'NOT_REQUIRED'; + if (refund.inventoryDisposition === 'RESTOCK') { + const items = await this.loadInventoryItems(connection, input.tenantId, order.id); + inventoryRequestId = internalRequestId('return', refund.clientRequestId); + await this.inventory.returnMany({ + tenantId: input.tenantId, storeId: order.storeId, items, + requestId: inventoryRequestId, businessType: 'PRODUCT_ORDER', + businessId: order.inventoryBusinessId, traceId: input.traceId, + operatorId: null, reason: '商品订单退款成功回补库存', + metadata: { refundId: refund.id, callbackId: input.callbackId } + }, connection); + inventoryStatus = 'SUCCEEDED'; + } + await connection.execute( + `UPDATE qipai_product_refunds + SET status = 'SUCCEEDED', inventory_status = ?, inventory_request_id = ?, + completed_at = UTC_TIMESTAMP(3), failure_code = '', version = version + 1 + WHERE tenant_id = ? AND id = ?`, + [inventoryStatus, inventoryRequestId, input.tenantId, refund.id] + ); + const refundedAmount = Number(payment.refundedAmountCents) + Number(refund.amountCents); + await connection.execute( + `UPDATE qipai_product_payments + SET refunded_amount_cents = ?, status = ?, version = version + 1 + WHERE tenant_id = ? AND id = ?`, + [refundedAmount, refundedAmount === Number(payment.amountCents) ? 'REFUNDED' : payment.status, + input.tenantId, payment.id] + ); + const version = Number(order.version) + 1; + await connection.execute( + `UPDATE qipai_product_orders + SET status = 'REFUNDED', refunded_amount_cents = refunded_amount_cents + ?, + inventory_status = ?, version = ? WHERE tenant_id = ? AND id = ?`, + [refund.amountCents, + inventoryStatus === 'SUCCEEDED' ? 'RETURNED' : order.inventoryStatus, + version, input.tenantId, order.id] + ); + await this.insertOrderEvent(connection, systemActor(input), { + orderId: order.id, storeId: order.storeId, + requestId: internalEventRequestId('refund-callback-order', input.callbackId), + fromStatus: order.status, toStatus: 'REFUNDED', action: 'REFUND_SUCCEEDED', + inventoryRequestId, versionAfter: version, reason: '', + metadata: { refundId: refund.id, amountCents: refund.amountCents } + }, 'CALLBACK'); + await this.insertRefundEvent(connection, systemActor(input), refund, { + requestId: internalEventRequestId('refund-callback', input.callbackId), + fromStatus: refund.status, toStatus: 'SUCCEEDED', + action: 'REFUND_SUCCEEDED', inventoryStatus, + inventoryRequestId, versionAfter: Number(refund.version) + 1, reason: '' + }, 'CALLBACK'); + await this.finishCallback(connection, callbackId, 'PROCESSED', ''); + return { refundId: refund.id, orderId: order.id, status: 'SUCCEEDED', idempotent: false }; + }); + if ('error' in result) throw new ProductOrderError(String(result.error)); + return result; + } + + async expirePendingOrders(limit = 100) { + const safeLimit = Math.max(1, Math.min(Math.trunc(limit), 500)); + return this.transaction(async (connection) => { + const [orders] = await connection.execute( + `SELECT ${ORDER_COLUMNS} + FROM qipai_product_orders o + WHERE o.status = 'PENDING_PAYMENT' AND o.inventory_status = 'LOCKED' + AND o.expires_at <= UTC_TIMESTAMP(3) AND o.deleted_at IS NULL + ORDER BY o.expires_at, o.id LIMIT ${safeLimit} FOR UPDATE SKIP LOCKED` + ); + let expired = 0; + for (const order of orders) { + await this.releasePendingInventory(connection, { + tenantId: order.tenantId, userId: '', access: emptyAccess(), source: 'MANAGEMENT', + traceId: `product-order-expire:${order.id}`, ip: '', userAgent: 'product-order-expirer' + }, order, '待支付商品订单超时'); + expired += 1; + } + return { expired }; + }); + } + + private async cancel( + actor: ProductOrderActor, orderId: string, requestId: string, + reason: string, management: boolean + ) { + if (!reason.trim()) throw new ProductOrderError('PRODUCT_ORDER_REASON_REQUIRED'); + return this.transaction(async (connection) => { + const replay = await this.findOrderEvent(connection, actor.tenantId, requestId); + if (replay) { + if (String(replay.orderId) !== orderId + || !['CANCEL', 'REFUND_REQUESTED'].includes(replay.action)) { + throw new ProductOrderError('PRODUCT_ORDER_IDEMPOTENCY_CONFLICT'); + } + return this.loadOrderView(connection, actor, orderId, management); + } + const order = management + ? await this.loadOrder(connection, actor.tenantId, orderId, true) + : await this.loadOrderOwnedByCustomer(connection, actor, orderId, true); + if (management) this.assertManagementStore(actor, order.storeId); + if (order.status === 'CANCELLED' || order.status === 'REFUNDED') { + return this.loadOrderView(connection, actor, orderId, management); + } + if (order.status === 'PENDING_PAYMENT') { + await this.releasePendingInventory(connection, actor, order, reason, requestId); + } else if (['PAID', 'ACCEPTED', 'COMPLETED'].includes(order.status)) { + await this.beginRefund(connection, actor, order, requestId, reason); + } else { + throw new ProductOrderError('PRODUCT_ORDER_CANCEL_NOT_ALLOWED'); + } + return this.loadOrderView(connection, actor, orderId, management); + }); + } + + private async releasePendingInventory( + connection: PoolConnection, actor: ProductOrderActor, order: OrderRow, + reason: string, eventRequestIdOverride?: string + ) { + if (order.inventoryStatus !== 'LOCKED') { + throw new ProductOrderError('PRODUCT_ORDER_INVENTORY_STATE_CONFLICT'); + } + const items = await this.loadInventoryItems(connection, order.tenantId, order.id); + const releaseRequestId = internalRequestId('release', order.clientRequestId); + await this.inventory.releaseMany({ + tenantId: order.tenantId, storeId: order.storeId, items, + requestId: releaseRequestId, businessType: 'PRODUCT_ORDER', + businessId: order.inventoryBusinessId, traceId: actor.traceId, + operatorId: actor.userId || null, ip: actor.ip, userAgent: actor.userAgent, + reason, metadata: { orderId: order.id } + }, connection); + const version = Number(order.version) + 1; + await connection.execute( + `UPDATE qipai_product_orders + SET status = 'CANCELLED', inventory_status = 'RELEASED', + cancelled_at = UTC_TIMESTAMP(3), version = ? + WHERE tenant_id = ? AND id = ?`, [version, order.tenantId, order.id] + ); + await connection.execute( + `UPDATE qipai_product_payments + SET status = 'CLOSED', version = version + 1 + WHERE tenant_id = ? AND order_id = ? AND status IN ('PENDING', 'PROCESSING')`, + [order.tenantId, order.id] + ); + const eventRequestId = eventRequestIdOverride + ?? internalEventRequestId('expire', order.clientRequestId); + await this.insertOrderEvent(connection, actor, { + orderId: order.id, storeId: order.storeId, requestId: eventRequestId, + fromStatus: order.status, toStatus: 'CANCELLED', action: 'CANCEL', + inventoryRequestId: releaseRequestId, versionAfter: version, reason, + metadata: { inventoryStatus: 'RELEASED' } + }, actor.userId ? undefined : 'SYSTEM'); + await this.audit(connection, actor, 'PRODUCT_ORDER_CANCELLED', + 'PRODUCT_ORDER', order.id, { reason, inventoryRequestId: releaseRequestId }); + } + + private async beginRefund( + connection: PoolConnection, actor: ProductOrderActor, order: OrderRow, + requestId: string, reason: string + ) { + if (!['PAID', 'ACCEPTED', 'COMPLETED', 'REFUND_FAILED'].includes(order.status)) { + throw new ProductOrderError('PRODUCT_REFUND_NOT_ALLOWED'); + } + if (!reason.trim()) throw new ProductOrderError('PRODUCT_ORDER_REASON_REQUIRED'); + const [payments] = await connection.execute( + `SELECT id, tenant_id AS tenantId, store_id AS storeId, order_id AS orderId, + payment_no AS paymentNo, client_request_id AS clientRequestId, + request_fingerprint AS requestFingerprint, provider, channel, status, + amount_cents AS amountCents, refunded_amount_cents AS refundedAmountCents, + expires_at AS expiresAt, paid_at AS paidAt, version + FROM qipai_product_payments + WHERE tenant_id = ? AND store_id = ? AND order_id = ? + AND status = 'SUCCEEDED' ORDER BY id DESC LIMIT 1 FOR UPDATE`, + [order.tenantId, order.storeId, order.id] + ); + const payment = payments[0]; + if (!payment) throw new ProductOrderError('PRODUCT_PAYMENT_NOT_FOUND'); + const amountCents = Number(payment.amountCents) - Number(payment.refundedAmountCents); + if (amountCents <= 0) throw new ProductOrderError('PRODUCT_REFUND_NOT_ALLOWED'); + const clientRequestId = `refund:${requestId}`.slice(0, 128); + const fingerprint = hashJson({ orderId: order.id, paymentId: payment.id, amountCents, reason }); + const refundNo = makeNumber('PR'); + const inventoryDisposition = order.status !== 'COMPLETED' + && order.inventoryStatus === 'DEDUCTED' ? 'RESTOCK' : 'NO_RESTOCK'; + const inventoryStatus = inventoryDisposition === 'RESTOCK' ? 'PENDING' : 'NOT_REQUIRED'; + const [inserted] = await connection.execute( + `INSERT INTO qipai_product_refunds + (tenant_id, store_id, order_id, payment_id, refund_no, client_request_id, + request_fingerprint, provider, status, amount_cents, reason, + inventory_disposition, inventory_status, requested_by, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?, 1)`, + [order.tenantId, order.storeId, order.id, payment.id, refundNo, clientRequestId, + fingerprint, payment.provider, amountCents, reason.trim(), + inventoryDisposition, inventoryStatus, actor.userId] + ); + const refundId = String(inserted.insertId); + const version = Number(order.version) + 1; + await connection.execute( + `UPDATE qipai_product_orders SET status = 'REFUNDING', version = ? + WHERE tenant_id = ? AND id = ?`, [version, order.tenantId, order.id] + ); + await this.insertOrderEvent(connection, actor, { + orderId: order.id, storeId: order.storeId, requestId, + fromStatus: order.status, toStatus: 'REFUNDING', action: 'REFUND_REQUESTED', + inventoryRequestId: null, versionAfter: version, reason, + metadata: { refundId, refundNo, amountCents, inventoryDisposition } + }); + await this.insertRefundEvent(connection, actor, { + id: refundId, tenantId: order.tenantId, storeId: order.storeId, + orderId: order.id, paymentId: payment.id, refundNo, amountCents + }, { + requestId: internalEventRequestId('refund', requestId), fromStatus: null, + toStatus: 'PENDING', action: 'REFUND_REQUESTED', inventoryStatus, + inventoryRequestId: null, versionAfter: 1, reason + }); + await this.audit(connection, actor, 'PRODUCT_REFUND_REQUESTED', + 'PRODUCT_REFUND', refundId, { orderId: order.id, paymentId: payment.id, amountCents }); + } + + private async assertStoreOpen( + connection: PoolConnection, tenantId: string, storeId: string, now: Date + ) { + const [rows] = await connection.execute( + `SELECT ps.id, s.timezone, s.business_status AS businessStatus, + ps.sales_status AS salesStatus, ps.manual_paused_at AS manualPausedAt, + ps.manual_paused_until AS manualPausedUntil + FROM qipai_stores s + LEFT JOIN qipai_product_store_settings ps + ON ps.tenant_id = s.tenant_id AND ps.store_id = s.id + WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL FOR SHARE`, + [tenantId, storeId] + ); + const settings = rows[0]; + if (!settings) throw new ProductOrderError('PRODUCT_ORDER_STORE_NOT_FOUND'); + let hours: ProductBusinessHourInput[] = []; + if (settings.id) { + const [hourRows] = await connection.execute( + `SELECT weekday, slot_no AS slotNo, open_minute AS openMinute, + close_minute AS closeMinute, crosses_midnight AS crossesMidnight + FROM qipai_product_store_hours + WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL FOR SHARE`, + [tenantId, storeId] + ); + hours = hourRows.map((row) => ({ + weekday: Number(row.weekday), slotNo: Number(row.slotNo), + openMinute: Number(row.openMinute), closeMinute: Number(row.closeMinute), + crossesMidnight: Boolean(row.crossesMidnight) + })); + } + try { + const open = isProductStoreOpenAt({ + timezone: settings.timezone, businessStatus: settings.businessStatus, + salesStatus: settings.salesStatus ?? 'OPEN', + manualPausedAt: settings.manualPausedAt, + manualPausedUntil: settings.manualPausedUntil, hours + }, now); + if (!open) throw new ProductOrderError('PRODUCT_ORDER_STORE_CLOSED'); + } catch (error) { + if (error instanceof ProductCatalogError) { + throw new ProductOrderError('PRODUCT_ORDER_STORE_CONFIGURATION_INVALID'); + } + throw error; + } + } + + private async assertActiveRoomOrder( + connection: PoolConnection, actor: ProductOrderActor, + storeId: string, roomOrderId: string, now: Date + ) { + const customerScope = actor.source === 'CUSTOMER' + ? `AND EXISTS ( + SELECT 1 FROM qipai_order_user_access a + WHERE a.tenant_id = o.tenant_id AND a.order_id = o.id + AND a.user_id = ? AND a.revoked_at IS NULL)` : ''; + const params: any[] = [actor.tenantId, storeId, roomOrderId, now, now]; + if (actor.source === 'CUSTOMER') params.push(actor.userId); + const [rows] = await connection.execute( + `SELECT o.id FROM qipai_orders o + WHERE o.tenant_id = ? AND o.store_id = ? AND o.id = ? + AND o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS') + AND o.start_at <= ? AND o.end_at > ? AND o.deleted_at IS NULL + ${customerScope} FOR SHARE`, params + ); + if (!rows[0]) throw new ProductOrderError('PRODUCT_ORDER_ROOM_ORDER_INVALID'); + } + + private async loadSkuSnapshots( + connection: PoolConnection, tenantId: string, storeId: string, + skuIds: string[], fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE', now: Date + ) { + const placeholders = skuIds.map(() => '?').join(','); + const [rows] = await connection.execute( + `SELECT s.id AS skuId, s.product_id AS productId, s.sku_code AS skuCode, + s.name AS skuName, s.attributes_json AS attributesSnapshot, + s.sale_price_cents AS unitPriceCents, + p.product_code AS productCode, p.name AS productName, + p.unit_name AS unitName, p.delivery_enabled AS deliveryEnabled, + l.fulfillment_mode AS fulfillmentMode, + l.sales_start_at AS salesStartAt, l.sales_end_at AS salesEndAt + FROM qipai_product_skus s + INNER JOIN qipai_products p + ON p.tenant_id = s.tenant_id AND p.id = s.product_id + AND p.status = 'ACTIVE' AND p.deleted_at IS NULL + INNER JOIN qipai_product_store_listings l + ON l.tenant_id = s.tenant_id AND l.product_id = s.product_id + AND l.store_id = ? AND l.status = 'ACTIVE' AND l.deleted_at IS NULL + WHERE s.tenant_id = ? AND s.id IN (${placeholders}) + AND s.status = 'ACTIVE' AND s.deleted_at IS NULL FOR SHARE`, + [storeId, tenantId, ...skuIds] + ); + return rows.filter((row) => { + const modeAllowed = row.fulfillmentMode === 'BOTH' || row.fulfillmentMode === fulfillmentMode; + const deliveryAllowed = fulfillmentMode !== 'DELIVERY' || Boolean(row.deliveryEnabled); + const windowAllowed = (!row.salesStartAt || row.salesStartAt <= now) + && (!row.salesEndAt || row.salesEndAt > now); + return modeAllowed && deliveryAllowed && windowAllowed && Number(row.unitPriceCents) > 0; + }); + } + + private async list(where: string[], params: any[], page: number, pageSize: number) { + const safePage = Math.max(1, Math.trunc(page)); + const safePageSize = Math.max(1, Math.min(Math.trunc(pageSize), 100)); + const [counts] = await this.pool.execute( + `SELECT COUNT(*) AS total FROM qipai_product_orders o WHERE ${where.join(' AND ')}`, + params + ); + const [rows] = await this.pool.execute( + `SELECT ${ORDER_COLUMNS} FROM qipai_product_orders o + WHERE ${where.join(' AND ')} + ORDER BY o.created_at DESC, o.id DESC + LIMIT ${safePageSize} OFFSET ${(safePage - 1) * safePageSize}`, + params + ); + return { + items: rows.map(publicOrder), total: Number(counts[0]?.total ?? 0), + page: safePage, pageSize: safePageSize + }; + } + + private async loadOrderView( + connection: PoolConnection, actor: ProductOrderActor, + orderId: string, management: boolean + ) { + const order = management + ? await this.loadOrder(connection, actor.tenantId, orderId, false) + : await this.loadOrderOwnedByCustomer(connection, actor, orderId, false); + if (management) this.assertManagementStore(actor, order.storeId); + const [items] = await connection.execute( + `SELECT id, line_no AS lineNo, product_id AS productId, sku_id AS skuId, + product_code AS productCode, product_name AS productName, + sku_code AS skuCode, sku_name AS skuName, unit_name AS unitName, + attributes_snapshot AS attributes, unit_price_cents AS unitPriceCents, + quantity, subtotal_cents AS subtotalCents, item_note AS note + FROM qipai_product_order_items + WHERE tenant_id = ? AND store_id = ? AND order_id = ? ORDER BY line_no`, + [actor.tenantId, order.storeId, order.id] + ); + const [payments] = await connection.execute( + `SELECT id, payment_no AS paymentNo, provider, channel, status, + amount_cents AS amountCents, refunded_amount_cents AS refundedAmountCents, + expires_at AS expiresAt, paid_at AS paidAt, created_at AS createdAt + FROM qipai_product_payments + WHERE tenant_id = ? AND store_id = ? AND order_id = ? ORDER BY id DESC`, + [actor.tenantId, order.storeId, order.id] + ); + const [refunds] = await connection.execute( + `SELECT id, payment_id AS paymentId, refund_no AS refundNo, provider, status, + amount_cents AS amountCents, reason, + inventory_disposition AS inventoryDisposition, + inventory_status AS inventoryStatus, completed_at AS completedAt, + created_at AS createdAt + FROM qipai_product_refunds + WHERE tenant_id = ? AND store_id = ? AND order_id = ? ORDER BY id DESC`, + [actor.tenantId, order.storeId, order.id] + ); + const [events] = await connection.execute( + `SELECT id, from_status AS fromStatus, to_status AS toStatus, action, + actor_type AS actorType, actor_id AS actorId, version_after AS versionAfter, + reason, created_at AS createdAt + FROM qipai_product_order_events + WHERE tenant_id = ? AND store_id = ? AND order_id = ? ORDER BY version_after`, + [actor.tenantId, order.storeId, order.id] + ); + return { ...publicOrder(order), items, payments, refunds, events }; + } + + private async loadOrder( + connection: PoolConnection, tenantId: string, orderId: string, lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT ${ORDER_COLUMNS} FROM qipai_product_orders o + WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL + ${lock ? 'FOR UPDATE' : ''}`, [tenantId, orderId] + ); + if (!rows[0]) throw new ProductOrderError('PRODUCT_ORDER_NOT_FOUND'); + return rows[0]; + } + + private async loadOrderOwnedByCustomer( + connection: PoolConnection, actor: ProductOrderActor, orderId: string, lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT ${ORDER_COLUMNS} FROM qipai_product_orders o + WHERE o.tenant_id = ? AND o.id = ? AND o.member_id = ? + AND o.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`, + [actor.tenantId, orderId, actor.userId] + ); + if (!rows[0]) throw new ProductOrderError('PRODUCT_ORDER_NOT_FOUND'); + return rows[0]; + } + + private async findByClientRequest( + tenantId: string, requestId: string, + executor: MySqlPool | PoolConnection = this.pool, lock = false + ) { + const [rows] = await executor.execute( + `SELECT ${ORDER_COLUMNS} FROM qipai_product_orders o + WHERE o.tenant_id = ? AND o.client_request_id = ? + ${lock ? 'FOR UPDATE' : ''}`, [tenantId, requestId] + ); + return rows[0] ?? null; + } + + private async resolveCreateReplay( + actor: ProductOrderActor, order: OrderRow, fingerprint: string, + connection?: PoolConnection + ) { + if (order.requestFingerprint !== fingerprint + || (actor.source === 'CUSTOMER' && String(order.memberId) !== actor.userId)) { + throw new ProductOrderError('PRODUCT_ORDER_IDEMPOTENCY_CONFLICT'); + } + if (connection) { + const view = await this.loadOrderView(connection, actor, order.id, actor.source === 'MANAGEMENT'); + return { ...view, idempotent: true }; + } + return this.transaction(async (tx) => ({ + ...await this.loadOrderView(tx, actor, order.id, actor.source === 'MANAGEMENT'), + idempotent: true + })); + } + + private async findOrderEvent(connection: PoolConnection, tenantId: string, requestId: string) { + const [rows] = await connection.execute( + `SELECT order_id AS orderId, action FROM qipai_product_order_events + WHERE tenant_id = ? AND request_id = ?`, [tenantId, requestId] + ); + return rows[0] ?? null; + } + + private async loadPayment( + connection: PoolConnection, tenantId: string, paymentId: string, lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT id, tenant_id AS tenantId, store_id AS storeId, order_id AS orderId, + payment_no AS paymentNo, client_request_id AS clientRequestId, + request_fingerprint AS requestFingerprint, provider, channel, status, + amount_cents AS amountCents, refunded_amount_cents AS refundedAmountCents, + expires_at AS expiresAt, paid_at AS paidAt, version + FROM qipai_product_payments WHERE tenant_id = ? AND id = ? + ${lock ? 'FOR UPDATE' : ''}`, [tenantId, paymentId] + ); + if (!rows[0]) throw new ProductOrderError('PRODUCT_PAYMENT_NOT_FOUND'); + return rows[0]; + } + + private async loadRefund( + connection: PoolConnection, tenantId: string, refundId: string, lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT id, tenant_id AS tenantId, store_id AS storeId, order_id AS orderId, + payment_id AS paymentId, refund_no AS refundNo, + client_request_id AS clientRequestId, + request_fingerprint AS requestFingerprint, provider, status, + amount_cents AS amountCents, + inventory_disposition AS inventoryDisposition, + inventory_status AS inventoryStatus, + inventory_request_id AS inventoryRequestId, version + FROM qipai_product_refunds WHERE tenant_id = ? AND id = ? + ${lock ? 'FOR UPDATE' : ''}`, [tenantId, refundId] + ); + if (!rows[0]) throw new ProductOrderError('PRODUCT_REFUND_NOT_FOUND'); + return rows[0]; + } + + private async findCallback( + connection: PoolConnection, tenantId: string, provider: string, callbackId: string + ) { + const [rows] = await connection.execute( + `SELECT payment_id AS paymentId, refund_id AS refundId, + payload_fingerprint AS payloadFingerprint, + processing_status AS processingStatus + FROM qipai_product_payment_callbacks + WHERE tenant_id = ? AND provider = ? AND callback_id = ? FOR UPDATE`, + [tenantId, provider, callbackId] + ); + return rows[0] ?? null; + } + + private async loadInventoryItems( + connection: PoolConnection, tenantId: string, orderId: string + ): Promise { + const [rows] = await connection.execute<(RowDataPacket & { skuId: string; quantity: number })[]>( + `SELECT sku_id AS skuId, SUM(quantity) AS quantity + FROM qipai_product_order_items + WHERE tenant_id = ? AND order_id = ? GROUP BY sku_id ORDER BY sku_id`, + [tenantId, orderId] + ); + if (rows.length === 0) throw new ProductOrderError('PRODUCT_ORDER_ITEMS_MISSING'); + return rows.map((row) => ({ skuId: String(row.skuId), quantity: Number(row.quantity) })); + } + + private async insertOrderEvent( + connection: PoolConnection, actor: ProductOrderActor, + event: { + orderId: string; storeId: string; requestId: string; + fromStatus: ProductOrderStatus | null; toStatus: ProductOrderStatus; + action: string; inventoryRequestId: string | null; versionAfter: number; + reason: string; metadata: Record; + }, actorTypeOverride?: 'CALLBACK' | 'SYSTEM' + ) { + const actorType = actorTypeOverride + ?? (actor.source === 'CUSTOMER' ? 'MEMBER' : 'STAFF'); + await connection.execute( + `INSERT INTO qipai_product_order_events + (tenant_id, store_id, order_id, request_id, from_status, to_status, + action, actor_type, actor_id, inventory_request_id, version_after, + reason, trace_id, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [actor.tenantId, event.storeId, event.orderId, event.requestId, + event.fromStatus, event.toStatus, event.action, actorType, + actorType === 'STAFF' ? actor.userId : null, event.inventoryRequestId, + event.versionAfter, event.reason.slice(0, 512), actor.traceId.slice(0, 128), + JSON.stringify(event.metadata)] + ); + } + + private async insertRefundEvent( + connection: PoolConnection, actor: ProductOrderActor, + refund: Pick, + event: { + requestId: string; fromStatus: RefundRow['status'] | null; + toStatus: RefundRow['status']; action: string; + inventoryStatus: RefundRow['inventoryStatus']; inventoryRequestId: string | null; + versionAfter: number; reason: string; + }, actorTypeOverride?: 'CALLBACK' | 'SYSTEM' + ) { + const actorType = actorTypeOverride ?? (actor.source === 'MANAGEMENT' ? 'STAFF' : 'SYSTEM'); + await connection.execute( + `INSERT INTO qipai_product_refund_events + (tenant_id, store_id, order_id, payment_id, refund_id, request_id, + from_status, to_status, action, inventory_status, inventory_request_id, + actor_type, actor_id, version_after, reason, trace_id, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [refund.tenantId, refund.storeId, refund.orderId, refund.paymentId, refund.id, + event.requestId, event.fromStatus, event.toStatus, event.action, + event.inventoryStatus, event.inventoryRequestId, actorType, + actorType === 'STAFF' ? actor.userId : null, event.versionAfter, + event.reason.slice(0, 512), actor.traceId.slice(0, 128), + JSON.stringify({ refundNo: refund.refundNo, amountCents: refund.amountCents })] + ); + } + + private async failRefund( + connection: PoolConnection, refund: RefundRow, order: OrderRow, + callbackId: string, code: string, input: ProductRefundResultInput + ) { + await connection.execute( + `UPDATE qipai_product_refunds + SET status = 'FAILED', inventory_status = ?, failure_code = ?, version = version + 1 + WHERE tenant_id = ? AND id = ?`, + [refund.inventoryDisposition === 'RESTOCK' ? 'PENDING' : 'NOT_REQUIRED', + code, input.tenantId, refund.id] + ); + const version = Number(order.version) + 1; + await connection.execute( + `UPDATE qipai_product_orders SET status = 'REFUND_FAILED', version = ? + WHERE tenant_id = ? AND id = ?`, [version, input.tenantId, order.id] + ); + const actor = systemActor(input); + await this.insertOrderEvent(connection, actor, { + orderId: order.id, storeId: order.storeId, + requestId: internalEventRequestId('refund-failed-order', input.callbackId), + fromStatus: order.status, toStatus: 'REFUND_FAILED', action: 'REFUND_FAILED', + inventoryRequestId: null, versionAfter: version, reason: code, + metadata: { refundId: refund.id } + }, 'CALLBACK'); + await this.insertRefundEvent(connection, actor, refund, { + requestId: internalEventRequestId('refund-failed', input.callbackId), + fromStatus: refund.status, toStatus: 'FAILED', action: 'REFUND_FAILED', + inventoryStatus: refund.inventoryDisposition === 'RESTOCK' ? 'PENDING' : 'NOT_REQUIRED', + inventoryRequestId: null, versionAfter: Number(refund.version) + 1, reason: code + }, 'CALLBACK'); + await this.finishCallback(connection, callbackId, 'FAILED', code); + } + + private async finishCallback( + connection: PoolConnection, callbackId: string, + status: 'PROCESSED' | 'FAILED' | 'IGNORED', errorCode: string + ) { + await connection.execute( + `UPDATE qipai_product_payment_callbacks + SET processing_status = ?, error_code = ?, processed_at = UTC_TIMESTAMP(3) + WHERE id = ?`, [status, errorCode, callbackId] + ); + } + + private assertManagementStore(actor: ProductOrderActor, storeId: string) { + if (actor.access.capabilities.includes('tenant.manage') + || actor.access.capabilities.includes('platform.manage') + || actor.access.roles.includes('PLATFORM_ADMIN')) return; + if (!actor.access.storeIds.includes(storeId)) { + throw new ProductOrderError('PRODUCT_ORDER_STORE_SCOPE_FORBIDDEN'); + } + } + + private async audit( + connection: PoolConnection, actor: ProductOrderActor, + action: string, resourceType: string, resourceId: string, + metadata: Record + ) { + await connection.execute( + `INSERT INTO qipai_audit_logs + (tenant_id, actor_type, actor_id, action, resource_type, resource_id, + trace_id, ip, user_agent, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [actor.tenantId, actor.userId ? 'USER' : 'SYSTEM', actor.userId || null, + action, resourceType, resourceId, actor.traceId.slice(0, 128), + actor.ip.slice(0, 64), actor.userAgent.slice(0, 255), JSON.stringify(metadata)] + ); + } + + private async transaction(work: (connection: PoolConnection) => Promise) { + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const connection = await this.pool.getConnection(); + try { + await connection.beginTransaction(); + const result = await work(connection); + await connection.commit(); + return result; + } catch (error) { + lastError = error; + try { + await connection.rollback(); + } catch { + // MySQL may already have rolled the transaction back after a deadlock. + } + if (!isRetryableTransactionError(error) || attempt === 3) { + throw translateInventoryError(error); + } + } finally { + connection.release(); + } + await new Promise((resolve) => setTimeout(resolve, attempt * 5)); + } + throw translateInventoryError(lastError); + } +} + +export function resolveProductOrderTransition( + status: ProductOrderStatus, + fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE', + action: ProductOrderManagementAction +): ProductOrderStatus { + if (action === 'ACCEPT' && status === 'PAID') return 'ACCEPTED'; + if (action === 'START_DELIVERY' && status === 'ACCEPTED' + && fulfillmentMode === 'DELIVERY') return 'DELIVERING'; + if (action === 'MARK_READY' && status === 'ACCEPTED' + && fulfillmentMode === 'SELF_SERVICE') return 'READY_FOR_SELF_SERVICE'; + if (action === 'COMPLETE' + && ((fulfillmentMode === 'DELIVERY' && status === 'DELIVERING') + || (fulfillmentMode === 'SELF_SERVICE' && status === 'READY_FOR_SELF_SERVICE'))) { + return 'COMPLETED'; + } + throw new ProductOrderError('PRODUCT_ORDER_TRANSITION_NOT_ALLOWED'); +} + +export function productOrderFingerprint(input: ProductOrderCreateInput) { + return hashJson({ + storeId: input.storeId, + fulfillmentMode: input.fulfillmentMode, + roomOrderId: input.roomOrderId ?? null, + note: input.note.trim(), + items: input.items.map((item) => ({ + skuId: item.skuId, quantity: item.quantity, note: item.note.trim() + })).sort((left, right) => left.skuId.localeCompare(right.skuId)) + }); +} + +function normalizeCreateInput(input: ProductOrderCreateInput): ProductOrderCreateInput { + assertId(input.storeId); assertRequestId(input.requestId); + if (!Array.isArray(input.items) || input.items.length < 1 || input.items.length > 100 + || input.note.trim().length > 512) { + throw new ProductOrderError('PRODUCT_ORDER_INPUT_INVALID'); + } + const roomOrderId = input.roomOrderId ?? null; + if (input.fulfillmentMode === 'DELIVERY') { + if (!roomOrderId) throw new ProductOrderError('PRODUCT_ORDER_ROOM_ORDER_REQUIRED'); + assertId(roomOrderId); + } else if (roomOrderId) { + throw new ProductOrderError('PRODUCT_ORDER_ROOM_ORDER_NOT_ALLOWED'); + } + const seen = new Set(); + const items = input.items.map((item) => { + assertId(item.skuId); + if (seen.has(item.skuId)) throw new ProductOrderError('PRODUCT_ORDER_SKU_DUPLICATE'); + seen.add(item.skuId); + if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 1_000_000_000 + || item.note.trim().length > 256) { + throw new ProductOrderError('PRODUCT_ORDER_INPUT_INVALID'); + } + return { skuId: item.skuId, quantity: item.quantity, note: item.note.trim() }; + }); + return { ...input, roomOrderId, note: input.note.trim(), items }; +} + +function publicOrder(row: OrderRow) { + return { + id: String(row.id), storeId: String(row.storeId), + memberId: row.memberId === null ? null : String(row.memberId), + roomOrderId: row.roomOrderId === null ? null : String(row.roomOrderId), + orderNo: row.orderNo, fulfillmentMode: row.fulfillmentMode, status: row.status, + itemCount: Number(row.itemCount), totalQuantity: Number(row.totalQuantity), + totalAmountCents: Number(row.totalAmountCents), paidAmountCents: Number(row.paidAmountCents), + refundedAmountCents: Number(row.refundedAmountCents), note: row.orderNote, + inventoryStatus: row.inventoryStatus, expiresAt: row.expiresAt, + paidAt: row.paidAt, acceptedAt: row.acceptedAt, deliveringAt: row.deliveringAt, + completedAt: row.completedAt, cancelledAt: row.cancelledAt, + version: Number(row.version), createdAt: row.createdAt, updatedAt: row.updatedAt, + idempotent: false + }; +} + +function publicPayment(row: PaymentRow, idempotent: boolean) { + return { + id: String(row.id), orderId: String(row.orderId), paymentNo: row.paymentNo, + provider: row.provider, channel: row.channel, status: row.status, + amountCents: Number(row.amountCents), refundedAmountCents: Number(row.refundedAmountCents), + expiresAt: row.expiresAt, paidAt: row.paidAt, idempotent + }; +} + +function assertId(value: string) { + if (!idPattern.test(value)) throw new ProductOrderError('PRODUCT_ORDER_INPUT_INVALID'); +} + +function assertRequestId(value: string) { + if (!requestIdPattern.test(value)) throw new ProductOrderError('PRODUCT_ORDER_INPUT_INVALID'); +} + +function hashJson(value: unknown) { + return createHash('sha256').update(stableJson(value)).digest('hex'); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const object = value as Record; + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function internalRequestId(kind: string, source: string) { + return `po.${kind}.${createHash('sha256').update(source).digest('hex').slice(0, 48)}`; +} + +function internalEventRequestId(kind: string, source: string) { + return `po.event.${kind}.${createHash('sha256').update(source).digest('hex')}`; +} + +function makeNumber(prefix: string) { + return `${prefix}${Date.now()}${randomBytes(6).toString('hex').toUpperCase()}`; +} + +function jsonOrNull(value: unknown) { + return value === null || value === undefined ? null + : typeof value === 'string' ? value : JSON.stringify(value); +} + +function isDuplicateKey(error: unknown) { + return Boolean(error && typeof error === 'object' + && 'code' in error && (error as { code?: string }).code === 'ER_DUP_ENTRY'); +} + +function translateInventoryError(error: unknown) { + if (!(error instanceof InventoryError)) return error; + const code = error.code === 'INVENTORY_INSUFFICIENT' + ? 'PRODUCT_ORDER_INVENTORY_INSUFFICIENT' + : error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT' + ? 'PRODUCT_ORDER_INVENTORY_IDEMPOTENCY_CONFLICT' + : `PRODUCT_ORDER_${error.code}`; + return new ProductOrderError(code); +} + +function isRetryableTransactionError(error: unknown) { + if (!error || typeof error !== 'object') return false; + const code = String(Reflect.get(error, 'code') ?? ''); + const errno = Number(Reflect.get(error, 'errno') ?? 0); + return code === 'ER_LOCK_DEADLOCK' || errno === 1213 + || code === 'ER_LOCK_WAIT_TIMEOUT' || errno === 1205; +} + +function systemActor(input: { tenantId: string; traceId: string }): ProductOrderActor { + return { + tenantId: input.tenantId, userId: '', access: emptyAccess(), source: 'MANAGEMENT', + traceId: input.traceId, ip: '', userAgent: 'product-payment-callback' + }; +} + +function emptyAccess(): AccessProfile { + return { roles: [], capabilities: [], storeIds: [] }; +} diff --git a/backend/src/routes/product-orders.ts b/backend/src/routes/product-orders.ts new file mode 100644 index 0000000..b5dda6d --- /dev/null +++ b/backend/src/routes/product-orders.ts @@ -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; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + 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 { + 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) { + 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 + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 32600c5..d96ed74 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => { diff --git a/backend/tests/inventory-service.test.mjs b/backend/tests/inventory-service.test.mjs index 3cda722..e1c6b3c 100644 --- a/backend/tests/inventory-service.test.mjs +++ b/backend/tests/inventory-service.test.mjs @@ -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.'); diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 78f033c..8e796d5 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 33f6e5e..2d01fdb 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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') diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index d2053db..273742e 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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.'); diff --git a/backend/tests/product-order-route.test.mjs b/backend/tests/product-order-route.test.mjs new file mode 100644 index 0000000..6d7293f --- /dev/null +++ b/backend/tests/product-order-route.test.mjs @@ -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.'); diff --git a/backend/tests/product-order-service.test.mjs b/backend/tests/product-order-service.test.mjs new file mode 100644 index 0000000..8c8c6b5 --- /dev/null +++ b/backend/tests/product-order-service.test.mjs @@ -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.'); diff --git a/database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql b/database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql new file mode 100644 index 0000000..80d34e0 --- /dev/null +++ b/database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql @@ -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'; diff --git a/database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql b/database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql new file mode 100644 index 0000000..9fd23a9 --- /dev/null +++ b/database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql @@ -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'); diff --git a/database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql b/database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql new file mode 100644 index 0000000..82e35fa --- /dev/null +++ b/database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql @@ -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}$'; diff --git a/docs/api-changelog/2026-08-11-M09-D2-product-orders.md b/docs/api-changelog/2026-08-11-M09-D2-product-orders.md new file mode 100644 index 0000000..e5550cb --- /dev/null +++ b/docs/api-changelog/2026-08-11-M09-D2-product-orders.md @@ -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 语义错误码。 diff --git a/docs/current-baseline.md b/docs/current-baseline.md index e4b1417..1f7b544 100644 --- a/docs/current-baseline.md +++ b/docs/current-baseline.md @@ -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 真实硬件联调继续等待外部条件,不阻断内部主队列。 diff --git a/docs/db-changelog/2026-08-11-M09-D2-product-order-payment-inventory.md b/docs/db-changelog/2026-08-11-M09-D2-product-order-payment-inventory.md new file mode 100644 index 0000000..110904c --- /dev/null +++ b/docs/db-changelog/2026-08-11-M09-D2-product-order-payment-inventory.md @@ -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`;并发支付单次扣减、取消/超时释放、退款返库、门店范围、事件不可变与跨阶段重试均通过。 diff --git a/docs/devlogs/2026-08-11-M09-D1-商品目录与库存底座.md b/docs/devlogs/2026-08-11-M09-D1-商品目录与库存底座.md index 34468d4..4f90244 100644 --- a/docs/devlogs/2026-08-11-M09-D1-商品目录与库存底座.md +++ b/docs/devlogs/2026-08-11-M09-D1-商品目录与库存底座.md @@ -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 文件显式暂存后最终复跑通过;提交前的未跟踪完整性提示未被记作功能失败。 ## 阶段结论 diff --git a/docs/devlogs/2026-08-11-M09-D2-商品订单与库存生命周期.md b/docs/devlogs/2026-08-11-M09-D2-商品订单与库存生命周期.md new file mode 100644 index 0000000..eae56ed --- /dev/null +++ b/docs/devlogs/2026-08-11-M09-D2-商品订单与库存生命周期.md @@ -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。 diff --git a/docs/feature-status.md b/docs/feature-status.md index 9d9bc34..20290b1 100644 --- a/docs/feature-status.md +++ b/docs/feature-status.md @@ -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 起业务资源继承门店范围。 | diff --git a/docs/module-status.md b/docs/module-status.md index bd98f31..3689dcb 100644 --- a/docs/module-status.md +++ b/docs/module-status.md @@ -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 | - | - | - | diff --git a/scripts/check-m09-d2-product-orders.mjs b/scripts/check-m09-d2-product-orders.mjs new file mode 100644 index 0000000..ed012ec --- /dev/null +++ b/scripts/check-m09-d2-product-orders.mjs @@ -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.' +); diff --git a/scripts/dev/windows/test-all.ps1 b/scripts/dev/windows/test-all.ps1 index 303bce1..0688e35 100644 --- a/scripts/dev/windows/test-all.ps1 +++ b/scripts/dev/windows/test-all.ps1 @@ -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 diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 28efb21..7eb79d3 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -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."