feat(M09-D3): 完成商品寄存与安全取出闭环
This commit is contained in:
@@ -136,9 +136,9 @@ sh scripts/setup-git-hooks.sh
|
|||||||
|
|
||||||
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
||||||
|
|
||||||
- 当前执行游标:`M09-D3`(TODO);`M09-D2` 已在当前工作树完成商品订单、支付衔接与库存生命周期收口,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
- 当前执行游标:`M09-D4`(TODO);`M09-D3` 已在当前工作树完成商品寄存与安全取出后端闭环,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
||||||
- 最近工程结果:M09-D2 已建立 7 张商品订单/支付/退款表、不可变订单项和事件、顾客与管理 API、门店范围权限,以及下单锁定、并发支付单次扣减、取消/超时释放、退款返库闭环;MySQL 8.4.10 的完整往返和并发回归通过,随本次中文工程提交固化。
|
- 最近工程结果:基于已推送工程提交 `1a3ea7b`,M09-D3 已建立寄存单、寄存明细、不可变状态事件和逐项数量流水;支持已完成商品订单或受控手工寄存、本人/授权员工分次取出、一次性哈希凭证轮换、取消/过期和通知 outbox。MySQL 8.4.10 的完整往返、跨租户、超量及并发防超领回归通过。
|
||||||
- 下一工程目标:执行 M09-D3,建立寄存单、分次取出流水、哈希领取凭证、本人/授权员工领取、并发防超领和通知 outbox。
|
- 下一工程目标:执行 M09-D4,在顾客小程序和运营后台接入商品目录、订单、库存及寄存/取出工作台,并完成响应式和权限门禁。
|
||||||
|
|
||||||
## 版本递进
|
## 版本递进
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ sh scripts/setup-git-hooks.sh
|
|||||||
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
|
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
|
||||||
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
|
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
|
||||||
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
|
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
|
||||||
- **V5.7**:增加中文 Git 提交门禁,持续收口 M09-D1 商品库存与 M09-D2 商品订单库存生命周期,游标推进至 M09-D3。
|
- **V5.7**:增加中文 Git 提交门禁,持续收口 M09-D1 至 D3 商品库存、订单与寄存取出生命周期,游标推进至 M09-D4。
|
||||||
|
|
||||||
## Codex 入口
|
## Codex 入口
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# 自助棋牌室系统开发总纲(V5.7)
|
# 自助棋牌室系统开发总纲(V5.7)
|
||||||
|
|
||||||
|
|
||||||
> 文档版本:V5.7(中文 Git 提交门禁 + `93af128` 最新已推送源码校准 + M09-D2 在制收口 + 持续工程开发版)
|
> 文档版本:V5.7(中文 Git 提交门禁 + `1a3ea7b` 最新已推送源码校准 + M09-D3 在制收口 + 持续工程开发版)
|
||||||
> 更新日期:2026-08-11(Asia/Shanghai)
|
> 更新日期:2026-08-11(Asia/Shanghai)
|
||||||
> 直接前序版本:V5.6;本文完整继承 V5.6 的全部功能、架构、协议、部署、安全、测试、验收和持续开发要求。
|
> 直接前序版本:V5.6;本文完整继承 V5.6 的全部功能、架构、协议、部署、安全、测试、验收和持续开发要求。
|
||||||
> 最新已审计提交证据:`93af128002c27ea548e9ed7d0c9af7b8f264002a`,且接管时 `HEAD == origin/main`;其后的 M09-D2 工作区代码待随本次中文工程提交固化。
|
> 最新已审计提交证据:`1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620`,且接管时 `HEAD == origin/main`;其后的 M09-D3 工作区代码待随本次中文工程提交固化。
|
||||||
> 默认执行起点:保护并继续完成当前 M09-D2 在制工作;通过编译、全量测试、真实 MySQL 往返和静态门禁并以中文提交推送后,进入 `M09-D3 → M09-D4 → M09-REGRESSION → M10-A/B/C/D → M10-RC1`。
|
> 默认执行起点:保护并继续完成当前 M09-D3 在制工作;通过编译、全量测试、真实 MySQL 往返和静态门禁并以中文提交推送后,进入 `M09-D4 → M09-REGRESSION → M10-A/B/C/D → M10-RC1`。
|
||||||
> 唯一交接目标:Codex 只阅读根目录 `V5.7.md`,即可保护当前工作区,直接继续工程编码、测试、中文提交、推送和远端校验,自动推进至 `CODE_COMPLETE`,外部条件具备后继续至 `PRODUCTION_ACCEPTED`。
|
> 唯一交接目标:Codex 只阅读根目录 `V5.7.md`,即可保护当前工作区,直接继续工程编码、测试、中文提交、推送和远端校验,自动推进至 `CODE_COMPLETE`,外部条件具备后继续至 `PRODUCTION_ACCEPTED`。
|
||||||
|
|
||||||
> **V5.7 最高优先级控制规则:** 本文开头“V5.7 增量控制层”和全文最后“第 30 章 V5.7 最终固定游标”共同构成唯一当前执行入口,优先于中间完整继承的 V5.6/V5.5/V5.4 历史标题、旧 HEAD、旧队列、旧示例和旧入口。中间历史正文继续作为完整需求与验收依据,但不得据此把执行游标回退到已经有工程证据的阶段。
|
> **V5.7 最高优先级控制规则:** 本文开头“V5.7 增量控制层”和全文最后“第 30 章 V5.7 最终固定游标”共同构成唯一当前执行入口,优先于中间完整继承的 V5.6/V5.5/V5.4 历史标题、旧 HEAD、旧队列、旧示例和旧入口。中间历史正文继续作为完整需求与验收依据,但不得据此把执行游标回退到已经有工程证据的阶段。
|
||||||
@@ -15,17 +15,18 @@
|
|||||||
|
|
||||||
# V5.7 增量控制层
|
# V5.7 增量控制层
|
||||||
|
|
||||||
## V5.7-0 M09-D2 收口覆盖层(2026-08-11 最新真实仓库)
|
## V5.7-0 M09-D3 收口覆盖层(2026-08-11 最新真实仓库)
|
||||||
|
|
||||||
本节记录 `93af128 + M09-D2 在制工作区` 的真实工程进展,优先于下方审计快照中的 M09-D1 恢复说明、旧 HEAD 和旧游标描述:
|
本节记录 `1a3ea7b + M09-D3 在制工作区` 的真实工程进展,优先于下方审计快照中的 M09-D1 恢复说明、旧 HEAD 和旧游标描述:
|
||||||
|
|
||||||
- Git 中文提交门禁已由 `2de796143ff418d681fdb972040b8555a8e058d1` 提交并推送;M09-D1 已由 `93af128002c27ea548e9ed7d0c9af7b8f264002a` 以 `feat(M09-D1): 完成商品目录与库存流水底座` 中文业务提交闭环,接管时 `HEAD == origin/main`。
|
- Git 中文提交门禁已由 `2de796143ff418d681fdb972040b8555a8e058d1` 提交并推送;M09-D1 已由 `93af128002c27ea548e9ed7d0c9af7b8f264002a` 以 `feat(M09-D1): 完成商品目录与库存流水底座` 中文业务提交闭环,接管时 `HEAD == origin/main`。
|
||||||
- M09-D2 当前工作树已完成 7 张商品订单/支付/退款表,商品订单与支付单分离,订单项固化商品、SKU、规格、价格、数量和金额快照,订单/退款事件由数据库触发器保持不可变。
|
- M09-D2 已由 `1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620` 以 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文业务提交闭环并推送,接管时 `HEAD == origin/main`。
|
||||||
- 顾客下单按服务端目录和门店价格复算并锁定库存;支付回调在死锁/锁等待时有限事务重试且只扣一次,取消/超时释放,退款按履约状态决定返库。配送订单校验本人有效房间订单,自助订单不绑定房间。
|
- M09-D3 当前工作树已完成寄存记录、寄存明细、不可变状态事件和逐项数量流水 4 张表;来源限定为已完成商品订单或受控员工手工创建。
|
||||||
- 顾客与管理 API、`goods.order.read/manage`、租户/门店范围和测试适配器生产隔离已接线;M09-D2 静态门禁、后端 build/full test 已通过。
|
- 顾客本人或授权员工可分次领取;领取凭证仅保存 SHA-256,部分领取后自动轮换,终态消费,创建和动作重放均不重新泄露已签发原文。行锁、版本 CAS、幂等事件和有限事务重试保证并发不超领。
|
||||||
- WSL MySQL 8.4.10 的 `up → verify → down → up → verify` 已通过,计数为 201 up、150 verify、187 down;并发支付单次扣减、取消/超时释放、退款返库、不可变事件和 M09-D1 跨阶段崩溃重试均有真实数据库证据。
|
- 顾客与管理 API、`goods.storage.read/manage`、租户/门店范围、审计和通知 outbox 已接线;M09-D3 静态门禁、后端 build/full test 已通过。
|
||||||
- M09-D2 工程与本文档伴随记录待以 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文业务提交取得 SHA;文档不得虚构尚未产生的提交号。
|
- WSL MySQL 8.4.10 的 `up → verify → down → up → verify` 已通过,计数为 217 up、159 verify、199 down;跨租户/跨寄存人拒绝、超量拒绝、部分取出、旧凭证失效、并发全量取出单赢家、取消、过期和不可变流水均有真实数据库证据。
|
||||||
- 唯一执行游标已推进到 `M09-D3`,下一目标为寄存单、分次取出、哈希领取凭证、并发防超领和通知 outbox。M09-D1/D2 若出现回归只做针对性修复,不得删除重做。
|
- M09-D3 工程与本文档伴随记录待以 `feat(M09-D3): 完成商品寄存与安全取出闭环` 中文业务提交取得 SHA;文档不得虚构尚未产生的提交号。
|
||||||
|
- 唯一执行游标已推进到 `M09-D4`,下一目标为顾客小程序与运营后台商品、订单、库存、寄存取出界面。M09-D1/D2/D3 若出现回归只做针对性修复,不得删除重做。
|
||||||
|
|
||||||
## V5.7-1 本次升级的唯一事实边界
|
## V5.7-1 本次升级的唯一事实边界
|
||||||
|
|
||||||
@@ -7070,14 +7071,14 @@ TARGET=CODE_COMPLETE_THEN_RELEASE_CANDIDATE_THEN_PRODUCTION_ACCEPTED
|
|||||||
execution_cursor_v57:
|
execution_cursor_v57:
|
||||||
document_version: V5.7
|
document_version: V5.7
|
||||||
audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f
|
audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f
|
||||||
evidence_head: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
evidence_head: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620
|
||||||
evidence_origin_main: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
evidence_origin_main: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620
|
||||||
base_branch: main
|
base_branch: main
|
||||||
remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git
|
remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git
|
||||||
current_stage: M09-D3
|
current_stage: M09-D4
|
||||||
current_stage_status: TODO_AFTER_M09_D2_WORKTREE_CLOSURE
|
current_stage_status: TODO_AFTER_M09_D3_WORKTREE_CLOSURE
|
||||||
last_completed_functional_stage: M09-D2
|
last_completed_functional_stage: M09-D3
|
||||||
last_engineering_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
last_engineering_commit: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620
|
||||||
last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1
|
last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1
|
||||||
m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
||||||
m09_d1_static_gate: PASS
|
m09_d1_static_gate: PASS
|
||||||
@@ -7086,13 +7087,19 @@ execution_cursor_v57:
|
|||||||
m09_d1_mysql_84_roundtrip: PASS_179_UP_141_VERIFY_171_DOWN
|
m09_d1_mysql_84_roundtrip: PASS_179_UP_141_VERIFY_171_DOWN
|
||||||
m09_d1_interrupted_up_down_retry: PASS
|
m09_d1_interrupted_up_down_retry: PASS
|
||||||
m09_d1_cleanup_probe: PASS
|
m09_d1_cleanup_probe: PASS
|
||||||
m09_d2_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
|
m09_d2_business_commit: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620
|
||||||
m09_d2_static_gate: PASS
|
m09_d2_static_gate: PASS
|
||||||
m09_d2_backend_build_and_full_test: PASS
|
m09_d2_backend_build_and_full_test: PASS
|
||||||
m09_d2_mysql_84_roundtrip: PASS_201_UP_150_VERIFY_187_DOWN
|
m09_d2_mysql_84_roundtrip: PASS_201_UP_150_VERIFY_187_DOWN
|
||||||
m09_d2_concurrent_payment_deduct: PASS
|
m09_d2_concurrent_payment_deduct: PASS
|
||||||
m09_d2_cancel_timeout_refund_inventory: PASS
|
m09_d2_cancel_timeout_refund_inventory: PASS
|
||||||
default_next_stage: M09-D4
|
m09_d3_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
|
||||||
|
m09_d3_static_gate: PASS
|
||||||
|
m09_d3_backend_build_and_full_test: PASS
|
||||||
|
m09_d3_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
|
||||||
|
m09_d3_one_time_credential: PASS
|
||||||
|
m09_d3_concurrent_retrieval: PASS
|
||||||
|
default_next_stage: M09-REGRESSION
|
||||||
actual_head_policy: REAL_REPOSITORY_EVIDENCE_WINS
|
actual_head_policy: REAL_REPOSITORY_EVIDENCE_WINS
|
||||||
worktree_protected: true
|
worktree_protected: true
|
||||||
unresolved_deletions_policy: EXCLUDE_FROM_STAGE_COMMIT_UNTIL_USER_CONFIRMS
|
unresolved_deletions_policy: EXCLUDE_FROM_STAGE_COMMIT_UNTIL_USER_CONFIRMS
|
||||||
@@ -7106,13 +7113,12 @@ execution_cursor_v57:
|
|||||||
auto_continue_until_code_complete: true
|
auto_continue_until_code_complete: true
|
||||||
```
|
```
|
||||||
|
|
||||||
真实 `D:\qipai` 若有 `93af128` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-D2 的推进依据是商品订单、支付库存工程、迁移、测试与真实 MySQL 共同证据,不是 docs-only 状态变更;若伴随业务提交尚未完成,先保护并提交当前工作树,再继续 M09-D3。
|
真实 `D:\qipai` 若有 `1a3ea7b` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-D3 的推进依据是寄存取出工程、迁移、测试与真实 MySQL 共同证据,不是 docs-only 状态变更;若伴随业务提交尚未完成,先保护并提交当前工作树,再继续 M09-D4。
|
||||||
|
|
||||||
## 30.2 最终唯一队列
|
## 30.2 最终唯一队列
|
||||||
|
|
||||||
```text
|
```text
|
||||||
M09-D3
|
M09-D4
|
||||||
→ M09-D4
|
|
||||||
→ M09-REGRESSION
|
→ M09-REGRESSION
|
||||||
→ M10-A
|
→ M10-A
|
||||||
→ M10-B
|
→ M10-B
|
||||||
@@ -7124,13 +7130,13 @@ M09-D3
|
|||||||
→ PRODUCTION_ACCEPTED
|
→ PRODUCTION_ACCEPTED
|
||||||
```
|
```
|
||||||
|
|
||||||
Git 中文门禁和 M09-D1 已由真实工程提交闭环;M09-D2 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M09-D3。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
|
Git 中文门禁和 M09-D1/D2 已由真实工程提交闭环;M09-D3 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M09-D4。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
|
||||||
|
|
||||||
M09-D2 的唯一允许推进条件:
|
M09-D3 的唯一允许推进条件:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
M09_D2_WORKTREE_PROTECTED=PASS
|
M09_D3_WORKTREE_PROTECTED=PASS
|
||||||
M09_D2_STATIC_GATE=PASS
|
M09_D3_STATIC_GATE=PASS
|
||||||
BACKEND_BUILD=PASS
|
BACKEND_BUILD=PASS
|
||||||
BACKEND_FULL_TEST=PASS
|
BACKEND_FULL_TEST=PASS
|
||||||
MYSQL_84_ROUNDTRIP=PASS
|
MYSQL_84_ROUNDTRIP=PASS
|
||||||
@@ -7141,7 +7147,7 @@ PUSH_VERIFIED=PASS
|
|||||||
REMOTE_COMMIT_SUBJECT_CN=PASS
|
REMOTE_COMMIT_SUBJECT_CN=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
任一项未通过时保持 `CURRENT_STAGE=M09-D2`,修复后重跑;不能先更新为 DONE 或进入 M09-D3。
|
任一项未通过时保持 `CURRENT_STAGE=M09-D3`,修复后重跑;不能先更新为 DONE 或进入 M09-D4。
|
||||||
|
|
||||||
## 30.3 每次提交和推送的最终语言门禁
|
## 30.3 每次提交和推送的最终语言门禁
|
||||||
|
|
||||||
@@ -7202,4 +7208,4 @@ USER_FACING_LANGUAGE=ZH_CN
|
|||||||
请阅读 V5.7.md,按当前进度继续开发。
|
请阅读 V5.7.md,按当前进度继续开发。
|
||||||
```
|
```
|
||||||
|
|
||||||
Codex 读取后直接核验真实仓库,保护已完成阶段与未决删除;若 M09-D2 工作树尚未提交,完成 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文 commit、push 和远端校验,然后从 M09-D3 建立寄存/取出安全闭环并沿唯一队列持续推进。禁止先生成替代计划文档,禁止只改 Markdown,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。
|
Codex 读取后直接核验真实仓库,保护已完成阶段与未决删除;若 M09-D3 工作树尚未提交,完成 `feat(M09-D3): 完成商品寄存与安全取出闭环` 中文 commit、push 和远端校验,然后从 M09-D4 建立顾客与运营端商品工作台并沿唯一队列持续推进。禁止先生成替代计划文档,禁止只改 Markdown,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"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",
|
"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:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||||
|
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-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"
|
"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": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ import {
|
|||||||
registerProductOrderRoutes,
|
registerProductOrderRoutes,
|
||||||
type ProductOrderRouteOptions
|
type ProductOrderRouteOptions
|
||||||
} from './routes/product-orders.js';
|
} from './routes/product-orders.js';
|
||||||
|
import {
|
||||||
|
registerProductStorageRoutes,
|
||||||
|
type ProductStorageRouteOptions
|
||||||
|
} from './routes/product-storages.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -99,6 +103,7 @@ export interface BuildAppOptions {
|
|||||||
products?: ProductRouteOptions;
|
products?: ProductRouteOptions;
|
||||||
inventory?: InventoryRouteOptions;
|
inventory?: InventoryRouteOptions;
|
||||||
productOrders?: ProductOrderRouteOptions;
|
productOrders?: ProductOrderRouteOptions;
|
||||||
|
productStorages?: ProductStorageRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -224,6 +229,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.productOrders) {
|
if (options.productOrders) {
|
||||||
await registerProductOrderRoutes(app, options.productOrders);
|
await registerProductOrderRoutes(app, options.productOrders);
|
||||||
}
|
}
|
||||||
|
if (options.productStorages) {
|
||||||
|
await registerProductStorageRoutes(app, options.productStorages);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,20 +143,23 @@ export class AuthRepository {
|
|||||||
OR (r.code = 'STAFF'
|
OR (r.code = 'STAFF'
|
||||||
AND p.code IN ('profile.read', 'store.operation.read',
|
AND p.code IN ('profile.read', 'store.operation.read',
|
||||||
'product.catalog.read', 'inventory.read',
|
'product.catalog.read', 'inventory.read',
|
||||||
'goods.order.read', 'goods.order.manage'))
|
'goods.order.read', 'goods.order.manage',
|
||||||
|
'goods.storage.read', 'goods.storage.manage'))
|
||||||
OR (r.code = 'STORE_ADMIN'
|
OR (r.code = 'STORE_ADMIN'
|
||||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||||
'store.operation.read', 'store.operation.write',
|
'store.operation.read', 'store.operation.write',
|
||||||
'device.read', 'device.write',
|
'device.read', 'device.write',
|
||||||
'product.catalog.read', 'product.catalog.write',
|
'product.catalog.read', 'product.catalog.write',
|
||||||
'inventory.read', 'inventory.adjust',
|
'inventory.read', 'inventory.adjust',
|
||||||
'goods.order.read', 'goods.order.manage'))
|
'goods.order.read', 'goods.order.manage',
|
||||||
|
'goods.storage.read', 'goods.storage.manage'))
|
||||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||||
'device.read', 'device.write',
|
'device.read', 'device.write',
|
||||||
'product.catalog.read', 'product.catalog.write',
|
'product.catalog.read', 'product.catalog.write',
|
||||||
'inventory.read', 'inventory.adjust',
|
'inventory.read', 'inventory.adjust',
|
||||||
'goods.order.read', 'goods.order.manage'))
|
'goods.order.read', 'goods.order.manage',
|
||||||
|
'goods.storage.read', 'goods.storage.manage'))
|
||||||
WHERE r.tenant_id = ?`,
|
WHERE r.tenant_id = ?`,
|
||||||
[input.context.tenantId, input.context.tenantId]
|
[input.context.tenantId, input.context.tenantId]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export class RbacRepository {
|
|||||||
OR (r.code = 'STAFF'
|
OR (r.code = 'STAFF'
|
||||||
AND p.code IN ('profile.read', 'store.operation.read',
|
AND p.code IN ('profile.read', 'store.operation.read',
|
||||||
'product.catalog.read', 'inventory.read',
|
'product.catalog.read', 'inventory.read',
|
||||||
'goods.order.read', 'goods.order.manage'))
|
'goods.order.read', 'goods.order.manage',
|
||||||
|
'goods.storage.read', 'goods.storage.manage'))
|
||||||
OR (r.code = 'CLEANER'
|
OR (r.code = 'CLEANER'
|
||||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||||
@@ -50,6 +51,7 @@ export class RbacRepository {
|
|||||||
'product.catalog.read', 'product.catalog.write',
|
'product.catalog.read', 'product.catalog.write',
|
||||||
'inventory.read', 'inventory.adjust',
|
'inventory.read', 'inventory.adjust',
|
||||||
'goods.order.read', 'goods.order.manage',
|
'goods.order.read', 'goods.order.manage',
|
||||||
|
'goods.storage.read', 'goods.storage.manage',
|
||||||
'cleaning.task.read', 'cleaning.task.write',
|
'cleaning.task.read', 'cleaning.task.write',
|
||||||
'cleaning.statistics.read'))
|
'cleaning.statistics.read'))
|
||||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
@@ -58,6 +60,7 @@ export class RbacRepository {
|
|||||||
'product.catalog.read', 'product.catalog.write',
|
'product.catalog.read', 'product.catalog.write',
|
||||||
'inventory.read', 'inventory.adjust',
|
'inventory.read', 'inventory.adjust',
|
||||||
'goods.order.read', 'goods.order.manage',
|
'goods.order.read', 'goods.order.manage',
|
||||||
|
'goods.storage.read', 'goods.storage.manage',
|
||||||
'cleaning.task.read', 'cleaning.task.write',
|
'cleaning.task.read', 'cleaning.task.write',
|
||||||
'cleaning.statistics.read'))
|
'cleaning.statistics.read'))
|
||||||
WHERE r.tenant_id = ?`,
|
WHERE r.tenant_id = ?`,
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.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'
|
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql',
|
||||||
|
'database/migrations/2026081109_m09d3_product_storage.up.sql'
|
||||||
],
|
],
|
||||||
verify: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -111,9 +112,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.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'
|
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql',
|
||||||
|
'database/migrations/2026081109_m09d3_product_storage.verify.sql'
|
||||||
],
|
],
|
||||||
down: [
|
down: [
|
||||||
|
'database/migrations/2026081109_m09d3_product_storage.down.sql',
|
||||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
|
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
|
||||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
||||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||||
@@ -370,7 +373,8 @@ async function executeMigrationPlanUnlocked(
|
|||||||
3, 7, 3, 1,
|
3, 7, 3, 1,
|
||||||
2, 8, 4, 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
|
7, 54, 22, 18, 34, 6, 2, 1, 1,
|
||||||
|
4, 30, 17, 14, 16, 4, 2, 1, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
|||||||
|
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 {
|
||||||
|
ProductStorageError,
|
||||||
|
productStorageStatuses,
|
||||||
|
type ProductStorageActor,
|
||||||
|
type ProductStorageService
|
||||||
|
} from '../products/product-storage-service.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,128}$/);
|
||||||
|
const expiresAt = z.coerce.date();
|
||||||
|
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 storageParams = z.object({ storageId: id }).strict();
|
||||||
|
const orderItem = z.object({ orderItemId: id, quantity: z.number().int().min(1).max(1_000_000_000) }).strict();
|
||||||
|
const skuItem = z.object({ skuId: id, quantity: z.number().int().min(1).max(1_000_000_000) }).strict();
|
||||||
|
const storageItem = z.object({
|
||||||
|
storageItemId: id, quantity: z.number().int().min(1).max(1_000_000_000)
|
||||||
|
}).strict();
|
||||||
|
const fromOrderSchema = z.object({
|
||||||
|
requestId, sourceOrderId: id, expiresAt,
|
||||||
|
items: z.array(orderItem).min(1).max(100).optional()
|
||||||
|
}).strict();
|
||||||
|
const managementCreateSchema = z.discriminatedUnion('sourceType', [
|
||||||
|
z.object({
|
||||||
|
sourceType: z.literal('ORDER'), requestId, sourceOrderId: id, expiresAt,
|
||||||
|
items: z.array(orderItem).min(1).max(100).optional()
|
||||||
|
}).strict(),
|
||||||
|
z.object({
|
||||||
|
sourceType: z.literal('MANUAL'), requestId, storeId: id, memberId: id,
|
||||||
|
expiresAt, items: z.array(skuItem).min(1).max(100)
|
||||||
|
}).strict()
|
||||||
|
]);
|
||||||
|
const customerListSchema = z.object({
|
||||||
|
storeId: id.optional(), status: z.enum(productStorageStatuses).optional(), page, pageSize
|
||||||
|
}).strict();
|
||||||
|
const managementListSchema = z.object({
|
||||||
|
storeId: id, memberId: id.optional(),
|
||||||
|
status: z.enum(productStorageStatuses).optional(), page, pageSize
|
||||||
|
}).strict();
|
||||||
|
const retrieveSchema = z.object({
|
||||||
|
requestId,
|
||||||
|
claimCredential: z.string().regex(/^[A-Za-z0-9_-]{20,128}$/),
|
||||||
|
items: z.array(storageItem).min(1).max(100)
|
||||||
|
}).strict();
|
||||||
|
const rotateSchema = z.object({ requestId }).strict();
|
||||||
|
const cancelSchema = z.object({
|
||||||
|
requestId, reason: z.string().trim().min(1).max(512)
|
||||||
|
}).strict();
|
||||||
|
const expireSchema = z.object({
|
||||||
|
storeId: id, limit: z.number().int().min(1).max(100).default(100)
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export interface ProductStorageRouteOptions {
|
||||||
|
service: Pick<ProductStorageService,
|
||||||
|
'createFromOrder' | 'createManual' | 'listForCustomer' | 'getForCustomer'
|
||||||
|
| 'listForManagement' | 'getForManagement' | 'retrieve'
|
||||||
|
| 'rotateCredential' | 'cancel' | 'expireDueForManagement'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerProductStorageRoutes(
|
||||||
|
app: FastifyInstance, options: ProductStorageRouteOptions
|
||||||
|
) {
|
||||||
|
app.post('/app-api/product-storages', async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, false);
|
||||||
|
const body = fromOrderSchema.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.createFromOrder(actor, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/app-api/product-storages', 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-storages/:storageId', async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, false);
|
||||||
|
const params = storageParams.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.storageId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/product-storages/:storageId/retrieve', async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, false);
|
||||||
|
const params = storageParams.safeParse(request.params);
|
||||||
|
const body = retrieveSchema.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.retrieve(actor, params.data.storageId, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/product-storages/:storageId/credential/rotate', async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, false);
|
||||||
|
const params = storageParams.safeParse(request.params);
|
||||||
|
const body = rotateSchema.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.rotateCredential(actor, params.data.storageId, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/product-storages/:storageId/cancel', async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, false);
|
||||||
|
const params = storageParams.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.cancel(actor, params.data.storageId, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const prefix of ['/admin-api', '/app-api/management']) {
|
||||||
|
app.post(`${prefix}/product-storages`, async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, true, true);
|
||||||
|
const body = managementCreateSchema.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: body.data.sourceType === 'ORDER'
|
||||||
|
? await options.service.createFromOrder(actor, body.data)
|
||||||
|
: await options.service.createManual(actor, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(`${prefix}/product-storages`, 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-storages/:storageId`, async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, true, false);
|
||||||
|
const params = storageParams.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.storageId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const action of ['retrieve', 'credential/rotate', 'cancel'] as const) {
|
||||||
|
app.post(`${prefix}/product-storages/:storageId/${action}`, async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, true, true);
|
||||||
|
const params = storageParams.safeParse(request.params);
|
||||||
|
const schema = action === 'retrieve' ? retrieveSchema
|
||||||
|
: action === 'cancel' ? cancelSchema : rotateSchema;
|
||||||
|
const body = schema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => {
|
||||||
|
const data = action === 'retrieve'
|
||||||
|
? await options.service.retrieve(
|
||||||
|
actor, params.data.storageId, body.data as z.infer<typeof retrieveSchema>
|
||||||
|
) : action === 'cancel'
|
||||||
|
? await options.service.cancel(
|
||||||
|
actor, params.data.storageId, body.data as z.infer<typeof cancelSchema>
|
||||||
|
) : await options.service.rotateCredential(
|
||||||
|
actor, params.data.storageId, body.data as z.infer<typeof rotateSchema>
|
||||||
|
);
|
||||||
|
return { code: 0, data, traceId: request.traceId };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post(`${prefix}/product-storages/expire-due`, async (request, reply) => {
|
||||||
|
const actor = await authenticate(request, reply, options, true, true);
|
||||||
|
const body = expireSchema.safeParse(request.body ?? {});
|
||||||
|
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0, data: await options.service.expireDueForManagement(actor, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: ProductStorageRouteOptions,
|
||||||
|
management: boolean,
|
||||||
|
write = false
|
||||||
|
): Promise<ProductStorageActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(
|
||||||
|
auth.session.tenantId, auth.session.user.id
|
||||||
|
);
|
||||||
|
if (management) {
|
||||||
|
const privileged = access.capabilities.includes('tenant.manage')
|
||||||
|
|| access.capabilities.includes('platform.manage')
|
||||||
|
|| access.roles.includes('PLATFORM_ADMIN');
|
||||||
|
const capability = write ? 'goods.storage.manage' : 'goods.storage.read';
|
||||||
|
if (!privileged && !access.capabilities.includes(capability)
|
||||||
|
&& !access.capabilities.includes('goods.storage.manage')) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'PRODUCT_STORAGE_OPERATION_FORBIDDEN',
|
||||||
|
message: 'Product storage permission is required.', traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||||
|
source: management ? 'MANAGEMENT' : 'CUSTOMER', traceId: request.traceId,
|
||||||
|
ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ProductStorageError)) throw error;
|
||||||
|
const status = error.code.includes('NOT_FOUND') ? 404
|
||||||
|
: error.code.includes('FORBIDDEN') ? 403
|
||||||
|
: error.code.includes('CONFLICT') || error.code.includes('ALREADY')
|
||||||
|
|| error.code.includes('NOT_ACTIVE') ? 409 : 400;
|
||||||
|
return reply.status(status).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The requested product storage operation is not available.', traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'PRODUCT_STORAGE_INPUT_INVALID',
|
||||||
|
message: 'The product storage input is invalid.', traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
|||||||
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
|
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
|
||||||
import { InventoryService } from './inventory/inventory-service.js';
|
import { InventoryService } from './inventory/inventory-service.js';
|
||||||
import { ProductOrderService } from './products/product-order-service.js';
|
import { ProductOrderService } from './products/product-order-service.js';
|
||||||
|
import { ProductStorageService } from './products/product-storage-service.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
@@ -60,6 +61,7 @@ const cleaningTaskRepository = new CleaningTaskRepository(pool);
|
|||||||
const productCatalogRepository = new ProductCatalogRepository(pool);
|
const productCatalogRepository = new ProductCatalogRepository(pool);
|
||||||
const inventoryService = new InventoryService(pool);
|
const inventoryService = new InventoryService(pool);
|
||||||
const productOrderService = new ProductOrderService(pool, inventoryService);
|
const productOrderService = new ProductOrderService(pool, inventoryService);
|
||||||
|
const productStorageService = new ProductStorageService(pool);
|
||||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||||
@@ -266,6 +268,12 @@ const app = await buildApp({
|
|||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret,
|
jwtSecret: config.auth.jwtSecret,
|
||||||
testAdapterEnabled: config.payment.testAdapterEnabled
|
testAdapterEnabled: config.payment.testAdapterEnabled
|
||||||
|
},
|
||||||
|
productStorages: {
|
||||||
|
service: productStorageService,
|
||||||
|
authRepository,
|
||||||
|
accessControl,
|
||||||
|
jwtSecret: config.auth.jwtSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ const productOrderPaymentDownSql = read(
|
|||||||
const productOrderPaymentVerifySql = read(
|
const productOrderPaymentVerifySql = read(
|
||||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||||
);
|
);
|
||||||
|
const productStorageUpSql = read(
|
||||||
|
'database/migrations/2026081109_m09d3_product_storage.up.sql'
|
||||||
|
);
|
||||||
|
const productStorageDownSql = read(
|
||||||
|
'database/migrations/2026081109_m09d3_product_storage.down.sql'
|
||||||
|
);
|
||||||
|
const productStorageVerifySql = read(
|
||||||
|
'database/migrations/2026081109_m09d3_product_storage.verify.sql'
|
||||||
|
);
|
||||||
|
|
||||||
const coreTables = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -684,4 +693,38 @@ assert.match(productOrderPaymentUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TE
|
|||||||
assert.match(productOrderPaymentVerifySql, /fully_granted_goods_order_roles/);
|
assert.match(productOrderPaymentVerifySql, /fully_granted_goods_order_roles/);
|
||||||
assert.match(productOrderPaymentVerifySql, /'2026081108'/);
|
assert.match(productOrderPaymentVerifySql, /'2026081108'/);
|
||||||
|
|
||||||
console.log('PASS: M01-B through M09-D2 migration contracts are present.');
|
for (const table of [
|
||||||
|
'qipai_product_storage_records',
|
||||||
|
'qipai_product_storage_items',
|
||||||
|
'qipai_product_storage_events',
|
||||||
|
'qipai_product_storage_event_items'
|
||||||
|
]) {
|
||||||
|
assert.match(productStorageUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||||
|
assert.match(productStorageDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||||
|
assert.match(productStorageVerifySql, new RegExp(`'${table}'`));
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(productStorageUpSql, /\bDECIMAL\b/i);
|
||||||
|
for (const state of [
|
||||||
|
'STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED'
|
||||||
|
]) assert.match(productStorageUpSql, new RegExp(`'${state}'`));
|
||||||
|
assert.match(productStorageUpSql, /claim_credential_hash CHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||||
|
assert.match(productStorageUpSql, /request_fingerprint CHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||||
|
assert.match(productStorageUpSql, /remaining_quantity <= total_quantity/);
|
||||||
|
assert.match(productStorageUpSql, /uq_qipai_product_storage_source_order/);
|
||||||
|
assert.match(productStorageUpSql, /uq_qipai_product_storage_event_request/);
|
||||||
|
assert.match(productStorageUpSql, /uq_qipai_product_storage_event_version/);
|
||||||
|
assert.match(productStorageUpSql, /qipai_product_storage_events_no_update/);
|
||||||
|
assert.match(productStorageUpSql, /qipai_product_storage_events_no_delete/);
|
||||||
|
assert.match(productStorageUpSql, /qipai_product_storage_event_items_no_update/);
|
||||||
|
assert.match(productStorageUpSql, /qipai_product_storage_event_items_no_delete/);
|
||||||
|
for (const permission of ['goods.storage.read', 'goods.storage.manage']) {
|
||||||
|
const pattern = new RegExp(permission.replace('.', '\\.'));
|
||||||
|
assert.match(productStorageUpSql, pattern);
|
||||||
|
assert.match(productStorageDownSql, pattern);
|
||||||
|
assert.match(productStorageVerifySql, pattern);
|
||||||
|
}
|
||||||
|
assert.match(productStorageUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
|
||||||
|
assert.match(productStorageVerifySql, /fully_granted_goods_storage_roles/);
|
||||||
|
assert.match(productStorageVerifySql, /'2026081109'/);
|
||||||
|
|
||||||
|
console.log('PASS: M01-B through M09-D3 migration contracts are present.');
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ 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, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.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.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql/);
|
||||||
|
assert.match(plan.file, /2026081109_m09d3_product_storage\.up\.sql$/);
|
||||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
@@ -64,12 +65,21 @@ assert.match(
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
verifyPlan.file,
|
verifyPlan.file,
|
||||||
/2026081108_m09d2_product_order_payment_inventory\.verify\.sql$/
|
/2026081108_m09d2_product_order_payment_inventory\.verify\.sql/
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
verifyPlan.file,
|
||||||
|
/2026081109_m09d3_product_storage\.verify\.sql$/
|
||||||
);
|
);
|
||||||
|
|
||||||
const downPlan = await loadMigrationPlan('down');
|
const downPlan = await loadMigrationPlan('down');
|
||||||
assert.match(downPlan.file, /^database\/migrations\/2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
assert.match(downPlan.file, /^database\/migrations\/2026081109_m09d3_product_storage\.down\.sql/);
|
||||||
|
assert.match(downPlan.file, /2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||||
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||||
|
assert.ok(
|
||||||
|
downPlan.file.indexOf('2026081109_m09d3_product_storage.down.sql')
|
||||||
|
< downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||||
< downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
< downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
ProductOrderError, ProductOrderService
|
ProductOrderError, ProductOrderService
|
||||||
} from '../dist/products/product-order-service.js';
|
} from '../dist/products/product-order-service.js';
|
||||||
|
import {
|
||||||
|
ProductStorageError, ProductStorageService, productStorageCredentialDigest
|
||||||
|
} from '../dist/products/product-storage-service.js';
|
||||||
import {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
loadMigrationPlan,
|
loadMigrationPlan,
|
||||||
@@ -118,6 +121,10 @@ const expectedTables = [
|
|||||||
'qipai_product_refund_events',
|
'qipai_product_refund_events',
|
||||||
'qipai_product_refunds',
|
'qipai_product_refunds',
|
||||||
'qipai_product_skus',
|
'qipai_product_skus',
|
||||||
|
'qipai_product_storage_event_items',
|
||||||
|
'qipai_product_storage_events',
|
||||||
|
'qipai_product_storage_items',
|
||||||
|
'qipai_product_storage_records',
|
||||||
'qipai_product_store_hours',
|
'qipai_product_store_hours',
|
||||||
'qipai_product_store_listings',
|
'qipai_product_store_listings',
|
||||||
'qipai_product_store_settings',
|
'qipai_product_store_settings',
|
||||||
@@ -174,15 +181,26 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
|||||||
repoRoot,
|
repoRoot,
|
||||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory'
|
'database/migrations/2026081108_m09d2_product_order_payment_inventory'
|
||||||
);
|
);
|
||||||
const [upSql, downSql, productOrderDownSql] = await Promise.all([
|
const productStorageMigrationBase = resolve(
|
||||||
|
repoRoot,
|
||||||
|
'database/migrations/2026081109_m09d3_product_storage'
|
||||||
|
);
|
||||||
|
const [upSql, downSql, productOrderDownSql, productStorageDownSql] = await Promise.all([
|
||||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||||
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
||||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8')
|
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8'),
|
||||||
|
readFile(`${productStorageMigrationBase}.down.sql`, 'utf8')
|
||||||
]);
|
]);
|
||||||
const upStatements = splitSqlStatements(upSql);
|
const upStatements = splitSqlStatements(upSql);
|
||||||
let productOrderDownAttempt = 0;
|
let productOrderDownAttempt = 0;
|
||||||
const removeProductOrderDependents = async () => {
|
const removeProductOrderDependents = async () => {
|
||||||
productOrderDownAttempt += 1;
|
productOrderDownAttempt += 1;
|
||||||
|
await executeMigrationPlan(pool, {
|
||||||
|
direction: 'down',
|
||||||
|
file: `${productStorageMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||||
|
checksum: `m09d3-before-m09d1-retry-${productOrderDownAttempt}`,
|
||||||
|
statements: splitSqlStatements(productStorageDownSql)
|
||||||
|
});
|
||||||
await executeMigrationPlan(pool, {
|
await executeMigrationPlan(pool, {
|
||||||
direction: 'down',
|
direction: 'down',
|
||||||
file: `${productOrderMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
file: `${productOrderMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||||
@@ -264,14 +282,14 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
||||||
'2026081006', '2026081107', '2026081108']
|
'2026081006', '2026081107', '2026081108', '2026081109']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1959,7 +1977,7 @@ async function assertSystemOperations(pool, context) {
|
|||||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||||
const overview = await repository.getSystemOverview(context.tenantId);
|
const overview = await repository.getSystemOverview(context.tenantId);
|
||||||
assert.equal(overview.tenant.id, context.tenantId);
|
assert.equal(overview.tenant.id, context.tenantId);
|
||||||
assert.equal(overview.latestMigration.version, '2026081108');
|
assert.equal(overview.latestMigration.version, '2026081109');
|
||||||
assert.ok(overview.counts.userCount > 0);
|
assert.ok(overview.counts.userCount > 0);
|
||||||
await repository.updateTenant(actor, context.tenantId, {
|
await repository.updateTenant(actor, context.tenantId, {
|
||||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||||
@@ -4311,6 +4329,299 @@ async function assertProductOrderPaymentInventory(pool, context) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertProductStorageLifecycle(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 [fixtureRows] = await pool.query(
|
||||||
|
`SELECT l.store_id AS storeId, s.id AS skuId
|
||||||
|
FROM qipai_product_skus s
|
||||||
|
INNER JOIN qipai_products p
|
||||||
|
ON p.tenant_id = s.tenant_id AND p.id = s.product_id
|
||||||
|
INNER JOIN qipai_product_store_listings l
|
||||||
|
ON l.tenant_id = p.tenant_id AND l.product_id = p.id
|
||||||
|
WHERE p.tenant_id = ? AND p.product_code = 'M09D2-ORDER-DRINK'
|
||||||
|
AND p.storage_enabled = 1 AND p.status = 'ACTIVE' AND p.deleted_at IS NULL
|
||||||
|
AND s.sku_code = 'M09D2-ORDER-DRINK-500'
|
||||||
|
AND s.status = 'ACTIVE' AND s.deleted_at IS NULL
|
||||||
|
AND l.status = 'ACTIVE' AND l.deleted_at IS NULL LIMIT 1`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
assert.ok(adminRows[0] && customerRows[0] && fixtureRows[0],
|
||||||
|
'M09-D3 requires the M09-D2 admin, customer and storage-enabled SKU fixtures.');
|
||||||
|
const adminId = String(adminRows[0].id);
|
||||||
|
const customerId = String(customerRows[0].id);
|
||||||
|
const storeId = String(fixtureRows[0].storeId);
|
||||||
|
const skuId = String(fixtureRows[0].skuId);
|
||||||
|
const rbac = new RbacRepository(pool);
|
||||||
|
const adminAccess = await rbac.getAccessProfile(context.tenantId, adminId);
|
||||||
|
for (const capability of ['goods.storage.read', 'goods.storage.manage']) {
|
||||||
|
assert.ok(adminAccess.capabilities.includes(capability),
|
||||||
|
`missing M09-D3 capability ${capability}`);
|
||||||
|
}
|
||||||
|
const adminActor = {
|
||||||
|
tenantId: context.tenantId, userId: adminId, access: adminAccess,
|
||||||
|
source: 'MANAGEMENT', traceId: 'm09d3-live-admin', ip: '127.0.0.1',
|
||||||
|
userAgent: 'M09-D3 live MySQL admin test'
|
||||||
|
};
|
||||||
|
const customerActor = {
|
||||||
|
tenantId: context.tenantId, userId: customerId,
|
||||||
|
access: await rbac.getAccessProfile(context.tenantId, customerId),
|
||||||
|
source: 'CUSTOMER', traceId: 'm09d3-live-customer', ip: '127.0.0.1',
|
||||||
|
userAgent: 'M09-D3 live MySQL customer test'
|
||||||
|
};
|
||||||
|
const inventory = new InventoryService(pool);
|
||||||
|
const orders = new ProductOrderService(pool, inventory, {
|
||||||
|
now: () => new Date('2026-08-10T17:00:00.000Z'), paymentHoldMinutes: 15
|
||||||
|
});
|
||||||
|
let credentialSequence = 0;
|
||||||
|
const storages = new ProductStorageService(pool, {
|
||||||
|
now: () => new Date('2026-08-10T18:00:00.000Z'),
|
||||||
|
credentialFactory: () => `m09d3_claim_${String(++credentialSequence).padStart(40, '0')}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceOrder = await orders.create(customerActor, {
|
||||||
|
storeId, requestId: 'm09d3-source-order', fulfillmentMode: 'SELF_SERVICE',
|
||||||
|
roomOrderId: null, note: 'M09-D3 storage source',
|
||||||
|
items: [{ skuId, quantity: 3, note: 'store after purchase' }]
|
||||||
|
});
|
||||||
|
const payment = await orders.createPaymentForCustomer(customerActor, sourceOrder.id, {
|
||||||
|
requestId: 'm09d3-source-payment', provider: 'TEST'
|
||||||
|
});
|
||||||
|
await orders.completeTestPaymentForCustomer(customerActor, payment.id, {
|
||||||
|
callbackId: 'm09d3-source-payment-callback', amountCents: 2100
|
||||||
|
});
|
||||||
|
await orders.managementAction(adminActor, sourceOrder.id, {
|
||||||
|
requestId: 'm09d3-source-accept', action: 'ACCEPT', reason: ''
|
||||||
|
});
|
||||||
|
await orders.managementAction(adminActor, sourceOrder.id, {
|
||||||
|
requestId: 'm09d3-source-ready', action: 'MARK_READY', reason: ''
|
||||||
|
});
|
||||||
|
const completedOrder = await orders.managementAction(adminActor, sourceOrder.id, {
|
||||||
|
requestId: 'm09d3-source-complete', action: 'COMPLETE', reason: ''
|
||||||
|
});
|
||||||
|
assert.equal(completedOrder.status, 'COMPLETED');
|
||||||
|
|
||||||
|
const createInput = {
|
||||||
|
requestId: 'm09d3-store-from-order', sourceOrderId: sourceOrder.id,
|
||||||
|
expiresAt: new Date('2026-08-12T18:00:00.000Z')
|
||||||
|
};
|
||||||
|
const stored = await storages.createFromOrder(customerActor, createInput);
|
||||||
|
assert.equal(stored.status, 'STORED');
|
||||||
|
assert.equal(stored.totalQuantity, 3);
|
||||||
|
assert.equal(stored.remainingQuantity, 3);
|
||||||
|
assert.match(stored.claimCredential, /^m09d3_claim_/);
|
||||||
|
assert.equal(stored.movements.length, 0);
|
||||||
|
const firstCredential = stored.claimCredential;
|
||||||
|
const storageItemId = String(stored.items[0].id);
|
||||||
|
const [credentialRows] = await pool.query(
|
||||||
|
`SELECT claim_credential_hash AS credentialHash
|
||||||
|
FROM qipai_product_storage_records WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[context.tenantId, stored.id]
|
||||||
|
);
|
||||||
|
assert.equal(credentialRows[0].credentialHash,
|
||||||
|
productStorageCredentialDigest(firstCredential));
|
||||||
|
assert.notEqual(credentialRows[0].credentialHash, firstCredential,
|
||||||
|
'only a credential digest may be persisted');
|
||||||
|
const replayedCreate = await storages.createFromOrder(customerActor, createInput);
|
||||||
|
assert.equal(replayedCreate.idempotent, true);
|
||||||
|
assert.equal(replayedCreate.claimCredential, null,
|
||||||
|
'an idempotent replay must never reveal the original raw credential');
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.createFromOrder(customerActor, {
|
||||||
|
...createInput, expiresAt: new Date('2026-08-13T18:00:00.000Z')
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_IDEMPOTENCY_CONFLICT'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.createFromOrder(customerActor, {
|
||||||
|
...createInput, requestId: 'm09d3-store-source-again'
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_SOURCE_ORDER_ALREADY_STORED'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.getForCustomer({
|
||||||
|
...customerActor, tenantId: String(BigInt(context.tenantId) + 999n)
|
||||||
|
}, stored.id),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_NOT_FOUND'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.getForCustomer({ ...customerActor, userId: '999999999999999999' }, stored.id),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_NOT_FOUND'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.retrieve(customerActor, stored.id, {
|
||||||
|
requestId: 'm09d3-over-quantity', claimCredential: firstCredential,
|
||||||
|
items: [{ storageItemId, quantity: 4 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_QUANTITY_EXCEEDED'
|
||||||
|
);
|
||||||
|
|
||||||
|
const partial = await storages.retrieve(customerActor, stored.id, {
|
||||||
|
requestId: 'm09d3-retrieve-partial', claimCredential: firstCredential,
|
||||||
|
items: [{ storageItemId, quantity: 1 }]
|
||||||
|
});
|
||||||
|
assert.equal(partial.status, 'PARTIALLY_RETRIEVED');
|
||||||
|
assert.equal(partial.remainingQuantity, 2);
|
||||||
|
assert.match(partial.nextClaimCredential, /^m09d3_claim_/);
|
||||||
|
assert.equal(partial.movements.length, 1);
|
||||||
|
assert.equal(Number(partial.movements[0].quantity), 1);
|
||||||
|
assert.equal(Number(partial.movements[0].remainingAfter), 2);
|
||||||
|
const secondCredential = partial.nextClaimCredential;
|
||||||
|
const partialReplay = await storages.retrieve(customerActor, stored.id, {
|
||||||
|
requestId: 'm09d3-retrieve-partial', claimCredential: firstCredential,
|
||||||
|
items: [{ storageItemId, quantity: 1 }]
|
||||||
|
});
|
||||||
|
assert.equal(partialReplay.idempotent, true);
|
||||||
|
assert.equal(partialReplay.nextClaimCredential, null);
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.retrieve(customerActor, stored.id, {
|
||||||
|
requestId: 'm09d3-old-credential-rejected', claimCredential: firstCredential,
|
||||||
|
items: [{ storageItemId, quantity: 1 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_CREDENTIAL_INVALID'
|
||||||
|
);
|
||||||
|
const retrievalRace = await Promise.allSettled([
|
||||||
|
storages.retrieve(customerActor, stored.id, {
|
||||||
|
requestId: 'm09d3-retrieve-race-a', claimCredential: secondCredential,
|
||||||
|
items: [{ storageItemId, quantity: 2 }]
|
||||||
|
}),
|
||||||
|
storages.retrieve(customerActor, stored.id, {
|
||||||
|
requestId: 'm09d3-retrieve-race-b', claimCredential: secondCredential,
|
||||||
|
items: [{ storageItemId, quantity: 2 }]
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
assert.equal(retrievalRace.filter((result) => result.status === 'fulfilled').length, 1,
|
||||||
|
'only one concurrent full retrieval may succeed');
|
||||||
|
const rejectedRetrieval = retrievalRace.find((result) => result.status === 'rejected');
|
||||||
|
assert.ok(rejectedRetrieval?.reason instanceof ProductStorageError);
|
||||||
|
assert.ok(['PRODUCT_STORAGE_NOT_ACTIVE', 'PRODUCT_STORAGE_CREDENTIAL_INVALID']
|
||||||
|
.includes(rejectedRetrieval.reason.code));
|
||||||
|
const retrieved = await storages.getForCustomer(customerActor, stored.id);
|
||||||
|
assert.equal(retrieved.status, 'RETRIEVED');
|
||||||
|
assert.equal(retrieved.remainingQuantity, 0);
|
||||||
|
assert.equal(retrieved.movements.reduce(
|
||||||
|
(sum, movement) => sum + Number(movement.quantity), 0
|
||||||
|
), 3);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.listForManagement({
|
||||||
|
...adminActor,
|
||||||
|
access: { roles: ['STAFF'], capabilities: ['goods.storage.read'], storeIds: [] }
|
||||||
|
}, { storeId, page: 1, pageSize: 20 }),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_STORE_SCOPE_FORBIDDEN'
|
||||||
|
);
|
||||||
|
const manual = await storages.createManual(adminActor, {
|
||||||
|
requestId: 'm09d3-manual-retrieve', storeId, memberId: customerId,
|
||||||
|
expiresAt: new Date('2026-08-12T18:00:00.000Z'),
|
||||||
|
items: [{ skuId, quantity: 3 }]
|
||||||
|
});
|
||||||
|
const manualItemId = String(manual.items[0].id);
|
||||||
|
const staffPartial = await storages.retrieve(adminActor, manual.id, {
|
||||||
|
requestId: 'm09d3-staff-retrieve', claimCredential: manual.claimCredential,
|
||||||
|
items: [{ storageItemId: manualItemId, quantity: 1 }]
|
||||||
|
});
|
||||||
|
assert.equal(staffPartial.status, 'PARTIALLY_RETRIEVED');
|
||||||
|
const rotated = await storages.rotateCredential(adminActor, manual.id, {
|
||||||
|
requestId: 'm09d3-rotate-manual'
|
||||||
|
});
|
||||||
|
assert.match(rotated.claimCredential, /^m09d3_claim_/);
|
||||||
|
await assert.rejects(
|
||||||
|
() => storages.retrieve(adminActor, manual.id, {
|
||||||
|
requestId: 'm09d3-before-rotate-credential',
|
||||||
|
claimCredential: staffPartial.nextClaimCredential,
|
||||||
|
items: [{ storageItemId: manualItemId, quantity: 1 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_CREDENTIAL_INVALID'
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelStorage = await storages.createManual(adminActor, {
|
||||||
|
requestId: 'm09d3-manual-cancel', storeId, memberId: customerId,
|
||||||
|
expiresAt: new Date('2026-08-12T18:00:00.000Z'),
|
||||||
|
items: [{ skuId, quantity: 1 }]
|
||||||
|
});
|
||||||
|
assert.equal((await storages.cancel(adminActor, cancelStorage.id, {
|
||||||
|
requestId: 'm09d3-cancel', reason: 'manual storage cancelled by authorized staff'
|
||||||
|
})).status, 'CANCELLED');
|
||||||
|
|
||||||
|
const expiringStorage = await storages.createManual(adminActor, {
|
||||||
|
requestId: 'm09d3-manual-expire', storeId, memberId: customerId,
|
||||||
|
expiresAt: new Date('2026-08-12T18:00:00.000Z'),
|
||||||
|
items: [{ skuId, quantity: 1 }]
|
||||||
|
});
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE qipai_product_storage_records SET expires_at = '2026-08-10 17:59:59.000'
|
||||||
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[context.tenantId, expiringStorage.id]
|
||||||
|
);
|
||||||
|
assert.ok((await storages.expireDueForManagement(adminActor, { storeId, limit: 100 })).expired >= 1);
|
||||||
|
assert.equal((await storages.getForManagement(adminActor, expiringStorage.id)).status, 'EXPIRED');
|
||||||
|
|
||||||
|
const [storageRows] = await pool.query(
|
||||||
|
`SELECT remaining_quantity AS remainingQuantity
|
||||||
|
FROM qipai_product_storage_records WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[context.tenantId, stored.id]
|
||||||
|
);
|
||||||
|
assert.equal(Number(storageRows[0].remainingQuantity), 0);
|
||||||
|
const [outboxRows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS total FROM qipai_outbox_events
|
||||||
|
WHERE tenant_id = ? AND aggregate_type = 'PRODUCT_STORAGE'`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
assert.ok(Number(outboxRows[0].total) >= 9);
|
||||||
|
const [auditRows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS total FROM qipai_audit_logs
|
||||||
|
WHERE tenant_id = ? AND resource_type = 'PRODUCT_STORAGE'`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
assert.ok(Number(auditRows[0].total) >= 9);
|
||||||
|
await assert.rejects(
|
||||||
|
() => pool.query(
|
||||||
|
`UPDATE qipai_product_storage_events SET reason = 'forbidden'
|
||||||
|
WHERE tenant_id = ? AND storage_id = ? LIMIT 1`,
|
||||||
|
[context.tenantId, stored.id]
|
||||||
|
),
|
||||||
|
(error) => /PRODUCT_STORAGE_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => pool.query(
|
||||||
|
`DELETE FROM qipai_product_storage_event_items
|
||||||
|
WHERE tenant_id = ? AND storage_id = ? LIMIT 1`,
|
||||||
|
[context.tenantId, stored.id]
|
||||||
|
),
|
||||||
|
(error) => /PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE/.test(error?.message ?? '')
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS: M09-D3 order/manual storage, one-time credentials, partial and concurrent '
|
||||||
|
+ 'retrieval, expiry, cancellation, isolation and immutable ledgers are consistent.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -4364,7 +4675,8 @@ try {
|
|||||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
{ 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' }
|
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||||
|
{ version: '2026081109', name: 'm09d3_product_storage' }
|
||||||
]);
|
]);
|
||||||
await assertTaskDurability(pool);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -4389,13 +4701,14 @@ try {
|
|||||||
await assertCleaningTaskTransactions(pool, loginContext);
|
await assertCleaningTaskTransactions(pool, loginContext);
|
||||||
await assertProductInventoryFoundation(pool, loginContext);
|
await assertProductInventoryFoundation(pool, loginContext);
|
||||||
await assertProductOrderPaymentInventory(pool, loginContext);
|
await assertProductOrderPaymentInventory(pool, loginContext);
|
||||||
|
await assertProductStorageLifecycle(pool, loginContext);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.down);
|
await executeMigrationPlan(pool, plans.down);
|
||||||
assert.deepEqual(await readCoreTables(pool), []);
|
assert.deepEqual(await readCoreTables(pool), []);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: down removed all M01-B through M09-D2 migration tables.');
|
console.log('PASS: down removed all M01-B through M09-D3 migration tables.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.up);
|
await executeMigrationPlan(pool, plans.up);
|
||||||
await executeMigrationPlan(pool, plans.verify);
|
await executeMigrationPlan(pool, plans.verify);
|
||||||
@@ -4427,7 +4740,8 @@ try {
|
|||||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
{ 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' }
|
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||||
|
{ version: '2026081109', name: 'm09d3_product_storage' }
|
||||||
]);
|
]);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||||
|
import { ProductStorageError } from '../dist/products/product-storage-service.js';
|
||||||
|
import { registerProductStorageRoutes } from '../dist/routes/product-storages.js';
|
||||||
|
|
||||||
|
const secret = 'test-only-product-storage-route-jwt-secret';
|
||||||
|
const sessionId = '80f21c7c-4c1d-4e62-bb82-301c6d51adea';
|
||||||
|
const token = signAccessToken({
|
||||||
|
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||||
|
}, secret, 900);
|
||||||
|
const headers = {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'x-trace-id': 'm09d3-product-storage-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 storage = {
|
||||||
|
id: '301', storeId: '11', memberId: '21', storageNo: 'PS301',
|
||||||
|
status: 'STORED', totalQuantity: 3, remainingQuantity: 3,
|
||||||
|
claimCredentialVersion: 1, claimCredentialConsumed: false,
|
||||||
|
items: [{ id: '401', skuId: '5', totalQuantity: 3, remainingQuantity: 3 }]
|
||||||
|
};
|
||||||
|
const service = {
|
||||||
|
createFromOrder: record('createFromOrder', () => ({
|
||||||
|
...storage, claimCredential: 'claim_credential_abcdefghijklmnopqrstuvwxyz012345'
|
||||||
|
})),
|
||||||
|
createManual: record('createManual', () => storage),
|
||||||
|
listForCustomer: record('listForCustomer', (_actor, input) => ({
|
||||||
|
items: [storage], total: 1, page: input.page, pageSize: input.pageSize
|
||||||
|
})),
|
||||||
|
getForCustomer: record('getForCustomer', (_actor, storageId) => {
|
||||||
|
if (storageId === '999') throw new ProductStorageError('PRODUCT_STORAGE_NOT_FOUND');
|
||||||
|
return storage;
|
||||||
|
}),
|
||||||
|
listForManagement: record('listForManagement', (_actor, input) => ({
|
||||||
|
items: [storage], total: 1, page: input.page, pageSize: input.pageSize
|
||||||
|
})),
|
||||||
|
getForManagement: record('getForManagement', () => storage),
|
||||||
|
retrieve: record('retrieve', () => ({
|
||||||
|
...storage, status: 'PARTIALLY_RETRIEVED', remainingQuantity: 2,
|
||||||
|
nextClaimCredential: 'next_claim_credential_abcdefghijklmnopqrstuvwxyz'
|
||||||
|
})),
|
||||||
|
rotateCredential: record('rotateCredential', () => ({
|
||||||
|
...storage, claimCredentialVersion: 2,
|
||||||
|
claimCredential: 'rotated_claim_credential_abcdefghijklmnopqrstuvwxyz'
|
||||||
|
})),
|
||||||
|
cancel: record('cancel', () => ({ ...storage, status: 'CANCELLED' })),
|
||||||
|
expireDueForManagement: record('expireDueForManagement', () => ({
|
||||||
|
expired: 1, storageIds: ['301']
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
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 registerProductStorageRoutes(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
|
||||||
|
});
|
||||||
|
|
||||||
|
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/product-storages' });
|
||||||
|
assert.equal(unauthorized.statusCode, 401);
|
||||||
|
|
||||||
|
const invalidCreate = await app.inject({
|
||||||
|
method: 'POST', url: '/app-api/product-storages', headers,
|
||||||
|
payload: { requestId: 'storage-create-invalid', sourceOrderId: '101', expiresAt: 'bad-date' }
|
||||||
|
});
|
||||||
|
assert.equal(invalidCreate.statusCode, 400);
|
||||||
|
assert.equal(invalidCreate.json().code, 'PRODUCT_STORAGE_INPUT_INVALID');
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST', url: '/app-api/product-storages', headers,
|
||||||
|
payload: {
|
||||||
|
requestId: 'storage-create-1', sourceOrderId: '101',
|
||||||
|
expiresAt: '2026-08-12T07:00:00.000Z',
|
||||||
|
items: [{ orderItemId: '201', quantity: 3 }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(created.statusCode, 201);
|
||||||
|
assert.equal(created.json().data.id, '301');
|
||||||
|
const createCall = calls.find((call) => call.method === 'createFromOrder');
|
||||||
|
assert.equal(createCall.args[0].source, 'CUSTOMER');
|
||||||
|
assert.ok(createCall.args[1].expiresAt instanceof Date);
|
||||||
|
|
||||||
|
const listed = await app.inject({
|
||||||
|
method: 'GET', url: '/app-api/product-storages?status=STORED&page=2&pageSize=10', headers
|
||||||
|
});
|
||||||
|
assert.equal(listed.statusCode, 200);
|
||||||
|
assert.equal(listed.json().data.page, 2);
|
||||||
|
|
||||||
|
const retrieved = await app.inject({
|
||||||
|
method: 'POST', url: '/app-api/product-storages/301/retrieve', headers,
|
||||||
|
payload: {
|
||||||
|
requestId: 'storage-retrieve-1',
|
||||||
|
claimCredential: 'claim_credential_abcdefghijklmnopqrstuvwxyz012345',
|
||||||
|
items: [{ storageItemId: '401', quantity: 1 }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(retrieved.statusCode, 200);
|
||||||
|
assert.equal(retrieved.json().data.status, 'PARTIALLY_RETRIEVED');
|
||||||
|
|
||||||
|
const missing = await app.inject({
|
||||||
|
method: 'GET', url: '/app-api/product-storages/999', headers
|
||||||
|
});
|
||||||
|
assert.equal(missing.statusCode, 404);
|
||||||
|
|
||||||
|
const managementForbidden = await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/product-storages?storeId=11', headers
|
||||||
|
});
|
||||||
|
assert.equal(managementForbidden.statusCode, 403);
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['STAFF'], capabilities: ['goods.storage.read'], storeIds: ['11']
|
||||||
|
};
|
||||||
|
const managementList = await app.inject({
|
||||||
|
method: 'GET', url: '/app-api/management/product-storages?storeId=11', headers
|
||||||
|
});
|
||||||
|
assert.equal(managementList.statusCode, 200);
|
||||||
|
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||||
|
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||||
|
|
||||||
|
const readOnlyCancel = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/product-storages/301/cancel', headers,
|
||||||
|
payload: { requestId: 'storage-cancel-1', reason: '管理员取消' }
|
||||||
|
});
|
||||||
|
assert.equal(readOnlyCancel.statusCode, 403);
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['STAFF'],
|
||||||
|
capabilities: ['goods.storage.read', 'goods.storage.manage'], storeIds: ['11']
|
||||||
|
};
|
||||||
|
const manual = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/product-storages', headers,
|
||||||
|
payload: {
|
||||||
|
sourceType: 'MANUAL', requestId: 'storage-manual-1', storeId: '11', memberId: '21',
|
||||||
|
expiresAt: '2026-08-12T07:00:00.000Z', items: [{ skuId: '5', quantity: 2 }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(manual.statusCode, 201);
|
||||||
|
assert.ok(calls.some((call) => call.method === 'createManual'));
|
||||||
|
|
||||||
|
const expired = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/product-storages/expire-due', headers,
|
||||||
|
payload: { storeId: '11', limit: 25 }
|
||||||
|
});
|
||||||
|
assert.equal(expired.statusCode, 200);
|
||||||
|
assert.equal(expired.json().data.expired, 1);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
console.log('PASS: M09-D3 storage routes enforce ownership, credential input and management permissions.');
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ProductStorageError,
|
||||||
|
ProductStorageService,
|
||||||
|
productStorageCredentialDigest,
|
||||||
|
resolveProductStorageRetrieval
|
||||||
|
} from '../dist/products/product-storage-service.js';
|
||||||
|
|
||||||
|
const credential = 'claim_credential_abcdefghijklmnopqrstuvwxyz012345';
|
||||||
|
const digest = productStorageCredentialDigest(credential);
|
||||||
|
assert.match(digest, /^[0-9a-f]{64}$/);
|
||||||
|
assert.equal(digest.includes(credential), false);
|
||||||
|
assert.equal(productStorageCredentialDigest(credential), digest);
|
||||||
|
assert.notEqual(productStorageCredentialDigest(`${credential}x`), digest);
|
||||||
|
|
||||||
|
assert.deepEqual(resolveProductStorageRetrieval('STORED', 5, 2), {
|
||||||
|
remainingQuantity: 3, status: 'PARTIALLY_RETRIEVED'
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveProductStorageRetrieval('PARTIALLY_RETRIEVED', 3, 3), {
|
||||||
|
remainingQuantity: 0, status: 'RETRIEVED'
|
||||||
|
});
|
||||||
|
for (const [status, quantity] of [['RETRIEVED', 1], ['EXPIRED', 1], ['CANCELLED', 1]]) {
|
||||||
|
assert.throws(
|
||||||
|
() => resolveProductStorageRetrieval(status, 1, quantity),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_NOT_ACTIVE'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() => resolveProductStorageRetrieval('STORED', 2, 3),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_QUANTITY_EXCEEDED'
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
tenantId: '7', userId: '31',
|
||||||
|
access: {
|
||||||
|
roles: ['STORE_ADMIN'], capabilities: ['goods.storage.read', 'goods.storage.manage'],
|
||||||
|
storeIds: ['11']
|
||||||
|
},
|
||||||
|
source: 'MANAGEMENT', traceId: 'm09d3-storage-service',
|
||||||
|
ip: '127.0.0.1', userAgent: 'storage-service-test'
|
||||||
|
};
|
||||||
|
const noSqlPool = {
|
||||||
|
async execute() { throw new Error('SQL must not run for rejected input'); },
|
||||||
|
async getConnection() { throw new Error('transaction must not start for rejected input'); }
|
||||||
|
};
|
||||||
|
const service = new ProductStorageService(noSqlPool, {
|
||||||
|
now: () => new Date('2026-08-11T07:00:00.000Z'),
|
||||||
|
credentialFactory: () => credential
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.createManual(manager, {
|
||||||
|
requestId: 'storage-create-duplicate', storeId: '11', memberId: '21',
|
||||||
|
expiresAt: new Date('2026-08-12T07:00:00.000Z'),
|
||||||
|
items: [{ skuId: '5', quantity: 1 }, { skuId: '5', quantity: 1 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_ITEMS_INVALID'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.createFromOrder({ ...manager, source: 'CUSTOMER', userId: '21' }, {
|
||||||
|
requestId: 'storage-expiry-invalid', sourceOrderId: '101',
|
||||||
|
expiresAt: new Date('2026-08-11T07:00:30.000Z')
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_EXPIRY_INVALID'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.retrieve({ ...manager, source: 'CUSTOMER', userId: '21' }, '301', {
|
||||||
|
requestId: 'storage-retrieve-invalid-credential', claimCredential: 'short',
|
||||||
|
items: [{ storageItemId: '401', quantity: 1 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductStorageError
|
||||||
|
&& error.code === 'PRODUCT_STORAGE_CREDENTIAL_INVALID'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('PASS: M09-D3 credential hashing, retrieval state transitions and input boundaries work.');
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_delete;
|
||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_update;
|
||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_events_no_delete;
|
||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_events_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.storage.read', 'goods.storage.manage');
|
||||||
|
|
||||||
|
DELETE FROM qipai_permissions
|
||||||
|
WHERE code IN ('goods.storage.read', 'goods.storage.manage');
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS qipai_product_storage_event_items;
|
||||||
|
DROP TABLE IF EXISTS qipai_product_storage_events;
|
||||||
|
DROP TABLE IF EXISTS qipai_product_storage_items;
|
||||||
|
DROP TABLE IF EXISTS qipai_product_storage_records;
|
||||||
|
|
||||||
|
ALTER TABLE qipai_product_order_items
|
||||||
|
DROP INDEX uq_qipai_product_order_item_scope_id;
|
||||||
|
|
||||||
|
DELETE FROM qipai_schema_migrations WHERE version = '2026081109';
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
ALTER TABLE qipai_product_order_items
|
||||||
|
ADD UNIQUE KEY uq_qipai_product_order_item_scope_id
|
||||||
|
(tenant_id, store_id, order_id, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_product_storage_records (
|
||||||
|
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 NOT NULL,
|
||||||
|
source_order_id BIGINT UNSIGNED NULL,
|
||||||
|
storage_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,
|
||||||
|
source_type VARCHAR(16) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'STORED',
|
||||||
|
item_count SMALLINT UNSIGNED NOT NULL,
|
||||||
|
total_quantity BIGINT UNSIGNED NOT NULL,
|
||||||
|
remaining_quantity BIGINT UNSIGNED NOT NULL,
|
||||||
|
expires_at DATETIME(3) NOT NULL,
|
||||||
|
claim_credential_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
claim_credential_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
claim_credential_rotated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
claim_credential_consumed_at DATETIME(3) NULL,
|
||||||
|
created_source VARCHAR(16) NOT NULL,
|
||||||
|
created_by BIGINT UNSIGNED NULL,
|
||||||
|
cancelled_at DATETIME(3) NULL,
|
||||||
|
cancel_reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
expired_at DATETIME(3) 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),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_tenant
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_store
|
||||||
|
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_member
|
||||||
|
FOREIGN KEY (tenant_id, member_id) REFERENCES qipai_users(tenant_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_source_order
|
||||||
|
FOREIGN KEY (tenant_id, store_id, source_order_id)
|
||||||
|
REFERENCES qipai_product_orders(tenant_id, store_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_created_by
|
||||||
|
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_scope_id (tenant_id, store_id, id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_no (tenant_id, storage_no),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_client_request (tenant_id, client_request_id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_source_order (tenant_id, source_order_id),
|
||||||
|
KEY idx_qipai_product_storage_store_status
|
||||||
|
(tenant_id, store_id, status, expires_at, id),
|
||||||
|
KEY idx_qipai_product_storage_member
|
||||||
|
(tenant_id, member_id, created_at, id),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_source CHECK (
|
||||||
|
(source_type = 'ORDER' AND source_order_id IS NOT NULL)
|
||||||
|
OR (source_type = 'MANUAL' AND source_order_id IS NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_status CHECK (
|
||||||
|
status IN ('STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_counts CHECK (
|
||||||
|
item_count BETWEEN 1 AND 100
|
||||||
|
AND total_quantity BETWEEN 1 AND 1000000000
|
||||||
|
AND remaining_quantity <= total_quantity
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_credential CHECK (
|
||||||
|
claim_credential_hash REGEXP '^[0-9a-f]{64}$'
|
||||||
|
AND claim_credential_version >= 1
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_actor CHECK (
|
||||||
|
created_source IN ('MEMBER', 'STAFF', 'SYSTEM')
|
||||||
|
AND (created_source <> 'MEMBER' OR source_type = 'ORDER')
|
||||||
|
AND (created_source <> 'STAFF' OR created_by IS NOT NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_version CHECK (version >= 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_product_storage_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
store_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
storage_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
line_no SMALLINT UNSIGNED NOT NULL,
|
||||||
|
source_order_id BIGINT UNSIGNED NULL,
|
||||||
|
source_order_item_id BIGINT UNSIGNED 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,
|
||||||
|
total_quantity BIGINT UNSIGNED NOT NULL,
|
||||||
|
remaining_quantity BIGINT UNSIGNED NOT NULL,
|
||||||
|
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),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_item_record
|
||||||
|
FOREIGN KEY (tenant_id, store_id, storage_id)
|
||||||
|
REFERENCES qipai_product_storage_records(tenant_id, store_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_item_source
|
||||||
|
FOREIGN KEY (tenant_id, store_id, source_order_id, source_order_item_id)
|
||||||
|
REFERENCES qipai_product_order_items(tenant_id, store_id, order_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_item_product
|
||||||
|
FOREIGN KEY (tenant_id, product_id) REFERENCES qipai_products(tenant_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_item_sku
|
||||||
|
FOREIGN KEY (tenant_id, sku_id) REFERENCES qipai_product_skus(tenant_id, id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_item_line
|
||||||
|
(tenant_id, store_id, storage_id, line_no),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_item_source
|
||||||
|
(tenant_id, source_order_item_id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_item_scope_id
|
||||||
|
(tenant_id, store_id, storage_id, id),
|
||||||
|
KEY idx_qipai_product_storage_item_sku
|
||||||
|
(tenant_id, store_id, sku_id, created_at),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_item_line CHECK (line_no BETWEEN 1 AND 100),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_item_source CHECK (
|
||||||
|
(source_order_id IS NULL AND source_order_item_id IS NULL)
|
||||||
|
OR (source_order_id IS NOT NULL AND source_order_item_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_item_quantity CHECK (
|
||||||
|
total_quantity BETWEEN 1 AND 1000000000
|
||||||
|
AND remaining_quantity <= total_quantity
|
||||||
|
)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_product_storage_events (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
store_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
storage_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
request_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
from_status VARCHAR(32) NULL,
|
||||||
|
to_status VARCHAR(32) NOT NULL,
|
||||||
|
action VARCHAR(32) NOT NULL,
|
||||||
|
quantity BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
actor_type VARCHAR(16) NOT NULL,
|
||||||
|
actor_id BIGINT UNSIGNED NULL,
|
||||||
|
credential_version_after INT UNSIGNED NOT 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_storage_event_record
|
||||||
|
FOREIGN KEY (tenant_id, store_id, storage_id)
|
||||||
|
REFERENCES qipai_product_storage_records(tenant_id, store_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_event_actor
|
||||||
|
FOREIGN KEY (tenant_id, actor_id) REFERENCES qipai_users(tenant_id, id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_event_request (tenant_id, request_id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_event_version
|
||||||
|
(tenant_id, store_id, storage_id, version_after),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_event_scope_id
|
||||||
|
(tenant_id, store_id, storage_id, id),
|
||||||
|
KEY idx_qipai_product_storage_event_history
|
||||||
|
(tenant_id, store_id, storage_id, created_at, id),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_event_fingerprint CHECK (
|
||||||
|
request_fingerprint REGEXP '^[0-9a-f]{64}$'
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_event_from_status CHECK (
|
||||||
|
from_status IS NULL OR from_status IN (
|
||||||
|
'STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_event_to_status CHECK (
|
||||||
|
to_status IN ('STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_event_action CHECK (
|
||||||
|
action IN ('STORED', 'RETRIEVED', 'CREDENTIAL_ROTATED', 'CANCELLED', 'EXPIRED')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_event_actor CHECK (
|
||||||
|
actor_type IN ('MEMBER', 'STAFF', 'SYSTEM')
|
||||||
|
AND (actor_type <> 'STAFF' OR actor_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_event_versions CHECK (
|
||||||
|
credential_version_after >= 1 AND version_after >= 1
|
||||||
|
)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_product_storage_event_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
store_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
storage_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
event_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
storage_item_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
line_no SMALLINT UNSIGNED NOT NULL,
|
||||||
|
sku_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
quantity BIGINT UNSIGNED NOT NULL,
|
||||||
|
remaining_after BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_event_item_event
|
||||||
|
FOREIGN KEY (tenant_id, store_id, storage_id, event_id)
|
||||||
|
REFERENCES qipai_product_storage_events(tenant_id, store_id, storage_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_event_item_storage_item
|
||||||
|
FOREIGN KEY (tenant_id, store_id, storage_id, storage_item_id)
|
||||||
|
REFERENCES qipai_product_storage_items(tenant_id, store_id, storage_id, id),
|
||||||
|
CONSTRAINT fk_qipai_product_storage_event_item_sku
|
||||||
|
FOREIGN KEY (tenant_id, sku_id) REFERENCES qipai_product_skus(tenant_id, id),
|
||||||
|
UNIQUE KEY uq_qipai_product_storage_event_item_line
|
||||||
|
(tenant_id, store_id, storage_id, event_id, line_no),
|
||||||
|
KEY idx_qipai_product_storage_event_item_storage
|
||||||
|
(tenant_id, store_id, storage_id, storage_item_id, created_at),
|
||||||
|
CONSTRAINT chk_qipai_product_storage_event_item_quantity CHECK (
|
||||||
|
line_no BETWEEN 1 AND 100
|
||||||
|
AND quantity BETWEEN 1 AND 1000000000
|
||||||
|
AND remaining_after <= 1000000000
|
||||||
|
)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_events_no_update;
|
||||||
|
CREATE TRIGGER qipai_product_storage_events_no_update
|
||||||
|
BEFORE UPDATE ON qipai_product_storage_events
|
||||||
|
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_IMMUTABLE';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_events_no_delete;
|
||||||
|
CREATE TRIGGER qipai_product_storage_events_no_delete
|
||||||
|
BEFORE DELETE ON qipai_product_storage_events
|
||||||
|
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_IMMUTABLE';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_update;
|
||||||
|
CREATE TRIGGER qipai_product_storage_event_items_no_update
|
||||||
|
BEFORE UPDATE ON qipai_product_storage_event_items
|
||||||
|
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS qipai_product_storage_event_items_no_delete;
|
||||||
|
CREATE TRIGGER qipai_product_storage_event_items_no_delete
|
||||||
|
BEFORE DELETE ON qipai_product_storage_event_items
|
||||||
|
FOR EACH ROW SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = 'PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE';
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
|
||||||
|
('goods.storage.read', '查看商品寄存', 'goods'),
|
||||||
|
('goods.storage.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.storage.read', 'goods.storage.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 ('2026081109', 'm09d3_product_storage');
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name IN (
|
||||||
|
'qipai_product_storage_records',
|
||||||
|
'qipai_product_storage_items',
|
||||||
|
'qipai_product_storage_events',
|
||||||
|
'qipai_product_storage_event_items'
|
||||||
|
)
|
||||||
|
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_storage_records' AND column_name IN (
|
||||||
|
'storage_no', 'client_request_id', 'request_fingerprint', 'source_type', 'status',
|
||||||
|
'total_quantity', 'remaining_quantity', 'expires_at', 'claim_credential_hash',
|
||||||
|
'claim_credential_version', 'claim_credential_consumed_at', 'version'
|
||||||
|
))
|
||||||
|
OR (table_name = 'qipai_product_storage_items' AND column_name IN (
|
||||||
|
'source_order_item_id', 'product_code', 'product_name', 'sku_code', 'sku_name',
|
||||||
|
'attributes_snapshot', 'total_quantity', 'remaining_quantity'
|
||||||
|
))
|
||||||
|
OR (table_name = 'qipai_product_storage_events' AND column_name IN (
|
||||||
|
'request_id', 'request_fingerprint', 'action', 'quantity',
|
||||||
|
'credential_version_after', 'version_after'
|
||||||
|
))
|
||||||
|
OR (table_name = 'qipai_product_storage_event_items' AND column_name IN (
|
||||||
|
'storage_item_id', 'sku_id', 'quantity', 'remaining_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_item_scope_id',
|
||||||
|
'uq_qipai_product_storage_scope_id',
|
||||||
|
'uq_qipai_product_storage_no',
|
||||||
|
'uq_qipai_product_storage_client_request',
|
||||||
|
'uq_qipai_product_storage_source_order',
|
||||||
|
'idx_qipai_product_storage_store_status',
|
||||||
|
'idx_qipai_product_storage_member',
|
||||||
|
'uq_qipai_product_storage_item_line',
|
||||||
|
'uq_qipai_product_storage_item_source',
|
||||||
|
'uq_qipai_product_storage_item_scope_id',
|
||||||
|
'idx_qipai_product_storage_item_sku',
|
||||||
|
'uq_qipai_product_storage_event_request',
|
||||||
|
'uq_qipai_product_storage_event_version',
|
||||||
|
'uq_qipai_product_storage_event_scope_id',
|
||||||
|
'idx_qipai_product_storage_event_history',
|
||||||
|
'uq_qipai_product_storage_event_item_line',
|
||||||
|
'idx_qipai_product_storage_event_item_storage'
|
||||||
|
)
|
||||||
|
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_storage_tenant',
|
||||||
|
'fk_qipai_product_storage_store',
|
||||||
|
'fk_qipai_product_storage_member',
|
||||||
|
'fk_qipai_product_storage_source_order',
|
||||||
|
'fk_qipai_product_storage_created_by',
|
||||||
|
'fk_qipai_product_storage_item_record',
|
||||||
|
'fk_qipai_product_storage_item_source',
|
||||||
|
'fk_qipai_product_storage_item_product',
|
||||||
|
'fk_qipai_product_storage_item_sku',
|
||||||
|
'fk_qipai_product_storage_event_record',
|
||||||
|
'fk_qipai_product_storage_event_actor',
|
||||||
|
'fk_qipai_product_storage_event_item_event',
|
||||||
|
'fk_qipai_product_storage_event_item_storage_item',
|
||||||
|
'fk_qipai_product_storage_event_item_sku'
|
||||||
|
)
|
||||||
|
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_storage_source',
|
||||||
|
'chk_qipai_product_storage_status',
|
||||||
|
'chk_qipai_product_storage_counts',
|
||||||
|
'chk_qipai_product_storage_credential',
|
||||||
|
'chk_qipai_product_storage_actor',
|
||||||
|
'chk_qipai_product_storage_version',
|
||||||
|
'chk_qipai_product_storage_item_line',
|
||||||
|
'chk_qipai_product_storage_item_source',
|
||||||
|
'chk_qipai_product_storage_item_quantity',
|
||||||
|
'chk_qipai_product_storage_event_fingerprint',
|
||||||
|
'chk_qipai_product_storage_event_from_status',
|
||||||
|
'chk_qipai_product_storage_event_to_status',
|
||||||
|
'chk_qipai_product_storage_event_action',
|
||||||
|
'chk_qipai_product_storage_event_actor',
|
||||||
|
'chk_qipai_product_storage_event_versions',
|
||||||
|
'chk_qipai_product_storage_event_item_quantity'
|
||||||
|
)
|
||||||
|
ORDER BY constraint_name;
|
||||||
|
|
||||||
|
SELECT trigger_name, event_manipulation
|
||||||
|
FROM information_schema.triggers
|
||||||
|
WHERE trigger_schema = DATABASE()
|
||||||
|
AND trigger_name IN (
|
||||||
|
'qipai_product_storage_events_no_update',
|
||||||
|
'qipai_product_storage_events_no_delete',
|
||||||
|
'qipai_product_storage_event_items_no_update',
|
||||||
|
'qipai_product_storage_event_items_no_delete'
|
||||||
|
)
|
||||||
|
ORDER BY trigger_name;
|
||||||
|
|
||||||
|
SELECT code
|
||||||
|
FROM qipai_permissions
|
||||||
|
WHERE code IN ('goods.storage.read', 'goods.storage.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.storage.read', 'goods.storage.manage')
|
||||||
|
GROUP BY granted_role.tenant_id, granted_role.id
|
||||||
|
HAVING COUNT(DISTINCT p.code) = 2
|
||||||
|
) fully_granted_goods_storage_roles
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT version, name
|
||||||
|
FROM qipai_schema_migrations
|
||||||
|
WHERE version = '2026081109'
|
||||||
|
AND name = 'm09d3_product_storage'
|
||||||
|
AND checksum REGEXP '^[0-9a-f]{64}$';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 2026-08-11 M09-D3 商品寄存 API
|
||||||
|
|
||||||
|
## 顾客接口
|
||||||
|
|
||||||
|
- `POST /app-api/product-storages`
|
||||||
|
- `GET /app-api/product-storages`
|
||||||
|
- `GET /app-api/product-storages/:storageId`
|
||||||
|
- `POST /app-api/product-storages/:storageId/retrieve`
|
||||||
|
- `POST /app-api/product-storages/:storageId/credential/rotate`
|
||||||
|
- `POST /app-api/product-storages/:storageId/cancel`
|
||||||
|
|
||||||
|
顾客创建时提交已完成商品订单、到期时间及可选订单项数量;列表、详情和动作只允许寄存本人访问。领取请求包含一次性凭证和逐项数量,部分领取成功后响应新的领取凭证。幂等重放返回当前视图,但不会再次返回任何已经签发的原始凭证。
|
||||||
|
|
||||||
|
## 管理接口
|
||||||
|
|
||||||
|
以下接口同时提供 `/admin-api` 与 `/app-api/management` 前缀:
|
||||||
|
|
||||||
|
- `POST /product-storages`
|
||||||
|
- `GET /product-storages?storeId=...`
|
||||||
|
- `GET /product-storages/:storageId`
|
||||||
|
- `POST /product-storages/:storageId/retrieve`
|
||||||
|
- `POST /product-storages/:storageId/credential/rotate`
|
||||||
|
- `POST /product-storages/:storageId/cancel`
|
||||||
|
- `POST /product-storages/expire-due`
|
||||||
|
|
||||||
|
读取要求 `goods.storage.read`,写入要求 `goods.storage.manage`;管理动作重复校验授权门店。手工创建必须绑定同租户有效顾客、有效门店上架 SKU 和未来到期时间。过期批处理显式限定当前租户与门店,内部 worker 另有全局 `SKIP LOCKED` 批处理入口。
|
||||||
|
|
||||||
|
## 返回与错误边界
|
||||||
|
|
||||||
|
详情包含寄存快照、当前剩余数量、状态事件和逐项取出流水,不包含凭证哈希。未找到或非本人资源返回 404 语义,权限/门店范围返回 403,状态、版本、同源或幂等冲突返回 409,凭证、数量、来源订单或到期时间不合法返回 400 语义错误码。
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# 当前开发成果基线
|
# 当前开发成果基线
|
||||||
|
|
||||||
> V5.0 首次核验日期:2026-06-16
|
> V5.0 首次核验日期:2026-06-16
|
||||||
> audited_commit: `93af128`
|
> audited_commit: `1a3ea7b`
|
||||||
> audited_worktree: M09-D2 当前工作树(待本次中文工程提交)
|
> audited_worktree: M09-D3 当前工作树(待本次中文工程提交)
|
||||||
> audited_at: `2026-08-11T06:58:52+08:00`
|
> audited_at: `2026-08-11T09:30:00+08:00`
|
||||||
> next_engineering_target: 按 V5.7 固定队列执行 M09-D3,建立安全寄存、分次取出、并发防超领和通知 outbox
|
> next_engineering_target: 按 V5.7 固定队列执行 M09-D4,接入商品、库存、订单与寄存取出的顾客和运营界面
|
||||||
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
|
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
|
||||||
|
|
||||||
## 总体结论
|
## 总体结论
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 总纲版本 | V5.7 已成为当前权威总纲,V5.6 及更早版本仅作历史基线 | 根目录 `V5.7.md` 的增量控制层与第 30 章固定队列 | 必须按 V5.7 游标开发,不得回退到历史队列 |
|
| 总纲版本 | 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` | 本地与远端同步 |
|
| 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/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 待本次中文工程提交取得 |
|
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 API;M09-A/B/C 已完成保洁收口,M09-D1/D2/D3 已完成商品目录、库存、订单支付和寄存取出安全生命周期 | `backend/src/products/**`、`backend/src/inventory/**`、`backend/src/routes/{products,inventory,product-orders,product-storages}.ts`、`database/migrations/202608110{7,8,9}_m09d*`、`backend/tests/product-*.test.mjs`、`backend/tests/inventory-*.test.mjs`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M09-D3 当前工作树的 build、全测、静态门禁和真实 MySQL 门禁通过,游标进入 M09-D4;提交 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 |
|
| 后台管理端 | 已建立 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 |
|
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路、保洁任务端照片上传/补做主体链路,以及管理员门店/房态/订单/代下单/验券/会员/员工/保洁/设备/经营统计 | `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-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;并发支付回调单次扣减、取消/超时释放、退款返库、不可变事件及跨阶段迁移重试通过 |
|
| 数据库迁移 | 已形成 M01-B 至 M09-D3 连续 up/down/verify 计划;M09-D3 新增 4 张寄存记录、明细、事件与事件项流水表及 4 个不可变触发器;M09-D1 崩溃重试会先按依赖撤销 D3/D2,迁移 runner 继续按 marker/checksum 增量执行并由数据库命名锁串行化 | `database/migrations/2026081109_m09d3_product_storage.{up,down,verify}.sql`、`backend/src/db/migration-runner.ts`、`backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 217 up、159 verify、199 down 的 up/verify/down/up/verify;一次性凭证、部分取出、并发全量取出单赢家、取消/过期、隔离和不可变流水通过 |
|
||||||
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh`、`scripts/setup/`、`deploy/` | PARTIAL,生产未执行 |
|
| 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 后接入 |
|
| 发布清单 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,仍需按模块迁移正式实现 |
|
| 参考资料 | 已有清单、脱敏日志、页面/接口/表结构摘要 | `docs/reference-*`、`docs/db-schema-inventory.md` | PARTIAL,仍需按模块迁移正式实现 |
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
## 状态校准
|
## 状态校准
|
||||||
|
|
||||||
- `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。
|
- `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。
|
||||||
- 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 尚未进入。
|
- M01-M04 与 M07 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`;M09-A/B/C/D1/D2/D3、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前按 V5.7 执行 M09-D4,M10 尚未进入。
|
||||||
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。
|
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。
|
||||||
|
|
||||||
## Git 历史敏感信息核验
|
## Git 历史敏感信息核验
|
||||||
@@ -37,6 +37,6 @@
|
|||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
1. 执行 M09-D3:建立寄存单、寄存明细和数量流水,支持 `STORED/PARTIALLY_RETRIEVED/RETRIEVED/EXPIRED/CANCELLED` 状态机。
|
1. 执行 M09-D4:在顾客小程序和运营后台接入商品目录、商品订单、库存与寄存/取出工作台,复用服务端权限、状态机与幂等契约。
|
||||||
2. 领取凭证只保存哈希,限制本人或授权门店员工领取;分次取出以事务行锁、稳定 requestId 和剩余数量条件阻止重复、超量与并发超领。
|
2. 领取凭证只保存哈希,限制本人或授权门店员工领取;分次取出以事务行锁、稳定 requestId 和剩余数量条件阻止重复、超量与并发超领。
|
||||||
3. 寄存、取出、取消和过期同事务写审计与通知 outbox;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。
|
3. 寄存、取出、取消和过期同事务写审计与通知 outbox;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 2026-08-11 M09-D3 商品寄存与取出流水
|
||||||
|
|
||||||
|
- 版本:`2026081109_m09d3_product_storage`。
|
||||||
|
- 新增 `qipai_product_storage_records`、`qipai_product_storage_items`、`qipai_product_storage_events`、`qipai_product_storage_event_items` 4 张表。
|
||||||
|
- 寄存记录保存租户、门店、寄存人、可选来源商品订单、状态、总量/余量、到期时间、领取凭证 SHA-256、凭证版本、消费时间、创建来源和乐观版本;不保存原始领取凭证。
|
||||||
|
- 寄存明细保存来源订单项、商品/SKU 代码和名称、规格、单位、总量及余量快照;订单来源用复合外键保持租户、门店、订单和订单项一致。
|
||||||
|
- 寄存事件保存幂等请求指纹、状态转换、动作、数量、操作人、凭证版本和资源版本;事件项逐行保存 SKU、本次取出量与取出后余量。
|
||||||
|
- 事件与事件项各有 UPDATE/DELETE 禁止触发器;客户端请求、订单来源、事件请求、事件版本和来源订单项使用唯一键防重。
|
||||||
|
- 新增 `goods.storage.read`、`goods.storage.manage` 并授予活动 STAFF、STORE_ADMIN、TENANT_ADMIN、PLATFORM_ADMIN;新租户通过认证/RBAC 初始化路径取得相同权限。
|
||||||
|
- down 先删除触发器和角色权限,再按事件项、事件、明细、主记录的依赖逆序删除表,最后删除商品订单项新增索引与 marker。
|
||||||
|
- verify 校验 4 张表、30 个关键列、17 个索引、14 个外键、16 个 CHECK、4 个触发器、2 项权限、角色授权和 migration marker。
|
||||||
|
- MySQL 8.4.10 已完成 217 up、159 verify、199 down 的完整往返;迁移中断恢复、并发取出、凭证轮换和不可变流水均通过。
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 工程结果
|
## 工程结果
|
||||||
|
|
||||||
- 工程状态:当前工作树已完成,待以 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文标题提交并取得 SHA。
|
- 工程状态:已由 `1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620` 以 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文标题提交并推送,且 `HEAD == origin/main` 校验通过。
|
||||||
- 新增独立的商品订单、订单项快照、订单事件、支付、退款、支付回调和退款事件领域;商品订单不复用房间订单或支付单状态,订单项固化商品/SKU 名称、规格、单价、数量、小计和备注。
|
- 新增独立的商品订单、订单项快照、订单事件、支付、退款、支付回调和退款事件领域;商品订单不复用房间订单或支付单状态,订单项固化商品/SKU 名称、规格、单价、数量、小计和备注。
|
||||||
- 顾客创建订单时由服务端读取有效门店上架、售价、销售开关和跨日营业时段并复算金额;`DELIVERY` 必须绑定本人当前有效房间订单,`SELF_SERVICE` 明确禁止伪造房间绑定。
|
- 顾客创建订单时由服务端读取有效门店上架、售价、销售开关和跨日营业时段并复算金额;`DELIVERY` 必须绑定本人当前有效房间订单,`SELF_SERVICE` 明确禁止伪造房间绑定。
|
||||||
- 下单按稳定订单业务号批量锁定库存;支付成功回调在同一事务扣减并推进 `PAID`,取消和待支付超时释放,退款按履约状态选择 `RESTOCK` 或 `NO_RESTOCK`。重复请求、重复回调和指纹冲突分别重放或明确拒绝。
|
- 下单按稳定订单业务号批量锁定库存;支付成功回调在同一事务扣减并推进 `PAID`,取消和待支付超时释放,退款按履约状态选择 `RESTOCK` 或 `NO_RESTOCK`。重复请求、重复回调和指纹冲突分别重放或明确拒绝。
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 2026-08-11 M09-D3 商品寄存与安全取出
|
||||||
|
|
||||||
|
## 工程结果
|
||||||
|
|
||||||
|
- 工程状态:当前工作树已完成,待以 `feat(M09-D3): 完成商品寄存与安全取出闭环` 中文标题提交并取得 SHA。
|
||||||
|
- 寄存来源限定为本人已完成商品订单或具备 `goods.storage.manage` 且拥有门店范围的员工手工创建;订单来源最多创建一张寄存单,手工来源必须绑定同租户有效顾客和当前门店有效上架 SKU。
|
||||||
|
- 状态机覆盖 `STORED`、`PARTIALLY_RETRIEVED`、`RETRIEVED`、`EXPIRED`、`CANCELLED`。每次取出锁定寄存单与明细、校验剩余数量和版本,逐项扣减后写不可变事件项流水。
|
||||||
|
- 领取凭证原文只在创建、主动轮换或部分取出后的响应中返回;数据库仅保存 SHA-256。部分取出自动产生下一版凭证,旧凭证立即失效;全量取出、取消和过期会消费凭证。
|
||||||
|
- 顾客只能查询和操作本人的寄存;管理端同时校验租户、`goods.storage.read/manage` 和授权门店范围。创建重放不会再次返回原始凭证,动作重放不会泄露轮换后的凭证。
|
||||||
|
- 寄存、取出、凭证轮换、取消和过期均在同一事务写审计与 `PRODUCT_STORAGE` outbox;死锁和锁等待超时进行最多 3 次完整事务重试。
|
||||||
|
|
||||||
|
## 数据库结果
|
||||||
|
|
||||||
|
`2026081109_m09d3_product_storage` 新增 4 张表:
|
||||||
|
|
||||||
|
1. `qipai_product_storage_records`
|
||||||
|
2. `qipai_product_storage_items`
|
||||||
|
3. `qipai_product_storage_events`
|
||||||
|
4. `qipai_product_storage_event_items`
|
||||||
|
|
||||||
|
迁移同时为商品订单项补充租户/门店/订单/主键复合唯一键,以支持寄存明细来源复合外键。寄存来源订单、客户端请求、事件请求、事件版本和订单项来源均有唯一约束;4 个触发器拒绝修改或删除寄存事件和逐项数量流水。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- `node scripts/check-m09-d3-product-storage.mjs`:PASS。
|
||||||
|
- 后端 `npm run build` 与全量 `npm test`:PASS;新增服务测试覆盖凭证摘要、取出状态转换和输入边界,路由测试覆盖顾客归属、管理权限与门店范围。
|
||||||
|
- WSL 原生临时副本 + MySQL 8.4.10:完成 `up → verify → down → up → verify`,217 条 up、159 条 verify、199 条 down;M09-D1 interrupted up/down 重试会按依赖先撤销 D3/D2并完整恢复。
|
||||||
|
- 真实 MySQL 业务用例覆盖订单寄存、手工寄存、创建幂等与指纹冲突、同源重复拒绝、跨租户/跨寄存人拒绝、超量拒绝、部分取出与旧凭证失效、并发全量取出单赢家、员工领取、主动轮换、取消、过期、outbox、审计和不可变触发器。
|
||||||
|
|
||||||
|
## 阶段结论
|
||||||
|
|
||||||
|
`M09-D3=DONE`。唯一执行游标进入 `M09-D4`,下一阶段在顾客小程序和运营后台接入商品目录、订单、库存与寄存/取出工作台。
|
||||||
@@ -50,7 +50,9 @@
|
|||||||
|
|
||||||
> 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-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;结果随本次中文工程提交固化。
|
> 2026-08-11 M09-D2:工程提交 `1a3ea7b` 为 `GOODS-102` 建立商品订单与支付库存闭环;7 张表分离商品订单、快照项、状态事件、支付、退款、支付回调和退款事件。下单按服务端目录/价格快照锁库存,支付回调在死锁/锁等待时有限重试并仅扣一次,取消/超时释放锁定,退款按订单履约状态决定返库且重复回调幂等。房间配送校验本人有效房间订单,自助场景不绑定房间;顾客和管理员/员工 API 复用租户、门店范围与 `goods.order.read/manage`。WSL MySQL 8.4.10 完成 201 up、150 verify、187 down 往返,游标进入 M09-D3。
|
||||||
|
|
||||||
|
> 2026-08-11 M09-D3:当前工作树为 `GOODS-104` 建立商品寄存与安全取出闭环;4 张表保存寄存记录、商品快照、不可变状态事件和逐项数量流水。已完成商品订单或受控员工可创建寄存,本人/授权员工可分次取出;领取凭证只保存 SHA-256、部分取出后单次轮换、终态消费,原文仅在创建/轮换响应出现。取消、过期和取出同事务写审计与 outbox。WSL MySQL 8.4.10 完成 217 up、159 verify、199 down 往返,并验证跨租户/跨寄存人拒绝、超量拒绝和并发全量取出单赢家,游标进入 M09-D4。
|
||||||
|
|
||||||
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
@@ -78,8 +80,9 @@
|
|||||||
| CLN-004 | 保洁结算 | M09-C | PARTIAL | `1f10715` | 同一任务成员份额由数据库生成唯一键保证最多一条有效结算明细,并发生成只有一个赢家;活动结算冻结成员奖励。取消保留原单/明细并生成负向冲正和状态事件,可重新生成;出款前 CAS、稳定请求号、商户隔离回调、成功重放、失败重试、人工已付、同源列表/导出和整数分均已闭环。协作任务按收款人分别付款,全部有效份额 PAID 后任务才进入 SETTLED。 | 真实微信商户凭据、生产小额转账和回调仍为 BLOCKED_EXTERNAL。 | 取得生产商户条件后执行小额转账、迟到回调和账单对账验收;内部工程不阻断 M09-D1。 |
|
| 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,补现场验收记录和更完整运营管理界面。 |
|
| 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 | `93af128` | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;代码/条码软删除后可复用,写操作用 expectedVersion CAS,跨日营业判断和租户/门店/角色越权拒绝有路由、仓储和真实 MySQL 证据。 | - | M09-D4 在顾客端和运营端复用同一目录,不重建第二套商品主数据。 |
|
| 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 的寄存数量独立记账,不回写已完成订单项快照。 |
|
| INV-001 | 库存策略与不可变流水 | M09-D1/M09-D2 | DONE | `93af128`、`1a3ea7b` | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;M09-D2 已把订单创建、支付、取消、超时和退款接入稳定业务号与同事务库存变更。请求指纹、版本 CAS、数量边界和不可变流水保证并发回调只扣一次、重复释放/返库幂等且不出现负库存。 | - | M09-D3 寄存数量已使用独立账本,不回写已完成订单项快照。 |
|
||||||
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2 | DONE | M09-D2 当前工作树(待本次中文工程提交) | 商品订单与支付单分离;订单项固化商品、SKU、规格、单价、数量和金额快照,状态事件不可变。顾客可下单、查询、取消,授权 STAFF/管理员可接单、配送/待取、完成、取消/退款;房间配送验证本人有效房间订单,自助订单不绑定房间。支付成功扣减、取消/超时释放、退款按履约状态返库均有回调幂等、事务重试、审计和 MySQL 8.4 并发证据。 | - | M09-D3 仅允许从符合条件的已完成商品订单创建寄存来源,并写通知 outbox。 |
|
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2 | DONE | `1a3ea7b` | 商品订单与支付单分离;订单项固化商品、SKU、规格、单价、数量和金额快照,状态事件不可变。顾客可下单、查询、取消,授权 STAFF/管理员可接单、配送/待取、完成、取消/退款;房间配送验证本人有效房间订单,自助订单不绑定房间。支付成功扣减、取消/超时释放、退款按履约状态返库均有回调幂等、事务重试、审计和 MySQL 8.4 并发证据。 | - | M09-D4 接入顾客和运营端商品订单界面。 |
|
||||||
|
| GOODS-104 | 商品寄存与安全取出 | M09-D3 | DONE | M09-D3 当前工作树(待本次中文工程提交) | 支持已完成商品订单或授权员工手工创建寄存,状态覆盖 STORED、PARTIALLY_RETRIEVED、RETRIEVED、EXPIRED、CANCELLED;商品/SKU/数量/会员/门店/到期时间固化。领取凭证仅存 SHA-256,部分取出自动轮换且旧凭证失效;本人或授权员工可领取,逐项数量流水不可变。并发领取行锁、版本 CAS、幂等事件和事务重试保证不超领,所有状态动作写审计与 outbox。 | - | M09-D4 接入顾客寄存详情、凭证操作与运营领取工作台。 |
|
||||||
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 |
|
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 |
|
||||||
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | 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 起业务资源继承门店范围。 |
|
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
```yaml
|
```yaml
|
||||||
execution_cursor:
|
execution_cursor:
|
||||||
current_module: M09
|
current_module: M09
|
||||||
current_stage: M09-D3
|
current_stage: M09-D4
|
||||||
stage_status: TODO
|
stage_status: TODO
|
||||||
last_completed_stage: M09-D2
|
last_completed_stage: M09-D3
|
||||||
next_stage: M09-D4
|
next_stage: M09-REGRESSION
|
||||||
last_engineering_commit: `93af128`
|
last_engineering_commit: `1a3ea7b`
|
||||||
pending_engineering_commit: "M09-D2 当前工作树(待本次中文工程提交)"
|
pending_engineering_commit: "M09-D3 当前工作树(待本次中文工程提交)"
|
||||||
last_push_verified: true
|
last_push_verified: true
|
||||||
base_branch: main
|
base_branch: main
|
||||||
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNAL;M08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNAL;M08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
||||||
updated_at: 2026-08-11T06:58:52+08:00
|
updated_at: 2026-08-11T09:30:00+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。 |
|
| 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 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
|
| 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。 |
|
| 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-D2 当前工作树(待本次中文工程提交) | docs/devlogs/2026-08-11-M09-D2-商品订单与库存生命周期.md | M09-A/B/C 已完成保洁收口;M09-D1 完成商品/库存底座,M09-D2 完成 7 张订单/支付/退款表、服务端快照、订单状态机、支付库存扣减、取消/超时释放、退款返库、顾客与管理 API、RBAC 和真实 MySQL 并发回归。当前进入 M09-D3,建设寄存/取出安全闭环。 |
|
| M09 保洁任务、结算、商品和库存 | DOING | M09-D3 当前工作树(待本次中文工程提交) | docs/devlogs/2026-08-11-M09-D3-商品寄存与安全取出.md | M09-A/B/C 已完成保洁收口;M09-D1/D2 完成商品、库存、订单和支付库存生命周期,M09-D3 完成寄存单、分次取出流水、一次性哈希凭证、本人/授权员工领取、取消/过期 outbox、RBAC 和真实 MySQL 并发防超领回归。当前进入 M09-D4,接入顾客与运营界面。 |
|
||||||
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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/2026081109_m09d3_product_storage.${suffix}.sql`)),
|
||||||
|
`M09-D3 ${suffix} migration is missing`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const migration = read('database/migrations/2026081109_m09d3_product_storage.up.sql');
|
||||||
|
for (const pattern of [
|
||||||
|
'qipai_product_storage_records',
|
||||||
|
'qipai_product_storage_items',
|
||||||
|
'qipai_product_storage_events',
|
||||||
|
'qipai_product_storage_event_items',
|
||||||
|
'PARTIALLY_RETRIEVED',
|
||||||
|
'claim_credential_hash',
|
||||||
|
'remaining_quantity',
|
||||||
|
'uq_qipai_product_storage_source_order',
|
||||||
|
'PRODUCT_STORAGE_EVENT_IMMUTABLE',
|
||||||
|
'PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE',
|
||||||
|
'goods.storage.read',
|
||||||
|
'goods.storage.manage'
|
||||||
|
]) assert.ok(migration.includes(pattern), `M09-D3 migration is missing ${pattern}`);
|
||||||
|
|
||||||
|
const service = read('backend/src/products/product-storage-service.ts');
|
||||||
|
for (const pattern of [
|
||||||
|
'ProductStorageService',
|
||||||
|
'createFromOrder',
|
||||||
|
'createManual',
|
||||||
|
'rotateCredential',
|
||||||
|
'expireDueForManagement',
|
||||||
|
'timingSafeEqual',
|
||||||
|
'FOR UPDATE',
|
||||||
|
'FOR UPDATE SKIP LOCKED',
|
||||||
|
'qipai_product_storage_event_items',
|
||||||
|
'qipai_outbox_events',
|
||||||
|
'qipai_audit_logs',
|
||||||
|
'PRODUCT_STORAGE_SOURCE_ORDER_ALREADY_STORED'
|
||||||
|
]) assert.ok(service.includes(pattern), `product storage service is missing ${pattern}`);
|
||||||
|
assert.match(service, /createHash\('sha256'\)/);
|
||||||
|
assert.match(service, /beginTransaction\(\)[\s\S]*commit\(\)[\s\S]*rollback\(\)/);
|
||||||
|
assert.match(service, /claimCredential: null/);
|
||||||
|
|
||||||
|
const routes = read('backend/src/routes/product-storages.ts');
|
||||||
|
for (const pattern of [
|
||||||
|
'/app-api/product-storages',
|
||||||
|
'/admin-api',
|
||||||
|
'/app-api/management',
|
||||||
|
'/credential/rotate',
|
||||||
|
'/expire-due',
|
||||||
|
'goods.storage.read',
|
||||||
|
'goods.storage.manage'
|
||||||
|
]) assert.ok(routes.includes(pattern), `product storage routes are missing ${pattern}`);
|
||||||
|
|
||||||
|
const app = read('backend/src/app.ts');
|
||||||
|
const server = read('backend/src/server.ts');
|
||||||
|
assert.match(app, /registerProductStorageRoutes/);
|
||||||
|
assert.match(app, /productStorages\?: ProductStorageRouteOptions/);
|
||||||
|
assert.match(server, /new ProductStorageService\(pool\)/);
|
||||||
|
|
||||||
|
const rbac = read('backend/src/auth/rbac-repository.ts');
|
||||||
|
assert.match(rbac, /'goods\.storage\.read', 'goods\.storage\.manage'/);
|
||||||
|
const packageJson = read('backend/package.json');
|
||||||
|
assert.match(packageJson, /product-storage-service\.test\.mjs/);
|
||||||
|
assert.match(packageJson, /product-storage-route\.test\.mjs/);
|
||||||
|
|
||||||
|
const mysqlTest = read('backend/tests/mysql-migration-roundtrip.test.mjs');
|
||||||
|
for (const pattern of [
|
||||||
|
'assertProductStorageLifecycle',
|
||||||
|
'only one concurrent full retrieval may succeed',
|
||||||
|
'PRODUCT_STORAGE_CREDENTIAL_INVALID',
|
||||||
|
'PRODUCT_STORAGE_EVENT_IMMUTABLE',
|
||||||
|
'PRODUCT_STORAGE_EVENT_ITEM_IMMUTABLE',
|
||||||
|
'expireDueForManagement'
|
||||||
|
]) assert.ok(mysqlTest.includes(pattern), `live MySQL test is missing ${pattern}`);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'PASS: M09-D3 storage ownership, one-time credential, partial/concurrent retrieval and immutable-ledger gates are present.'
|
||||||
|
);
|
||||||
@@ -42,6 +42,8 @@ node scripts/check-m09-d1-product-inventory.mjs
|
|||||||
Assert-NativeSuccess "check-m09-d1-product-inventory"
|
Assert-NativeSuccess "check-m09-d1-product-inventory"
|
||||||
node scripts/check-m09-d2-product-orders.mjs
|
node scripts/check-m09-d2-product-orders.mjs
|
||||||
Assert-NativeSuccess "check-m09-d2-product-orders"
|
Assert-NativeSuccess "check-m09-d2-product-orders"
|
||||||
|
node scripts/check-m09-d3-product-storage.mjs
|
||||||
|
Assert-NativeSuccess "check-m09-d3-product-storage"
|
||||||
|
|
||||||
if (Test-Path "admin/package.json") {
|
if (Test-Path "admin/package.json") {
|
||||||
Push-Location admin
|
Push-Location admin
|
||||||
|
|||||||
@@ -107,6 +107,6 @@ export QIPAI_MYSQL_USER="${username}"
|
|||||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
||||||
|
|
||||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-D2 integration roundtrip in a temporary database."
|
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-D3 integration roundtrip in a temporary database."
|
||||||
npm --prefix backend run test:mysql:migration
|
npm --prefix backend run test:mysql:migration
|
||||||
echo "PASS: M01-B through M09-D2 live MySQL integration roundtrip completed."
|
echo "PASS: M01-B through M09-D3 live MySQL integration roundtrip completed."
|
||||||
|
|||||||
Reference in New Issue
Block a user