feat(M09-D4): 完成商品选购与运营工作台

This commit is contained in:
Codex
2026-08-11 08:06:13 +08:00
parent 60264cbd11
commit e91b979128
32 changed files with 1065 additions and 60 deletions
+4 -4
View File
@@ -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-D4`TODO);`M09-D3` 已在当前工作树完成商品寄存与安全取出后端闭环,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL` - 当前执行游标:`M09-REGRESSION`TODO);M09-D4 顾客小程序与 Vue 商品运营工作台已完成交付级回归,随本次中文工程提交固化;真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`
- 最近工程结果:基于已推送工程提交 `1a3ea7b`,M09-D3 已建立寄存单、寄存明细、不可变状态事件和逐项数量流水;支持已完成商品订单或受控手工寄存、本人/授权员工分次取出、一次性哈希凭证轮换、取消/过期和通知 outbox。MySQL 8.4.10 的完整往返、跨租户、超量及并发防超领回归通过 - 最近工程结果:已推送工程提交 `60264cb` 完成 M09-D3 商品寄存与安全取出闭环;当前 M09-D4 随本次中文工程提交固化登录态顾客目录、选购/订单/寄存小程序页,以及按目录、库存、订单、寄存能力分域的响应式运营工作台。领取码只在签发当次显示,不写入小程序本地缓存
- 下一工程目标:执行 M09-D4,在顾客小程序和运营后台接入商品目录、订单、库存及寄存/取出工作台,并完成响应式和权限门禁 - 下一工程目标:执行 `M09-REGRESSION`,完成商品目录、库存、订单支付/退款、寄存数量、权限与界面的全链路对账和模块级验收
## 版本递进 ## 版本递进
@@ -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 至 D3 商品库存、订单寄存取出生命周期,游标推进至 M09-D4 - **V5.7**:增加中文 Git 提交门禁,持续收口 M09-D1 至 D4 商品库存、订单寄存取出与端到端运营体验,游标推进至 M09 模块回归前夕
## Codex 入口 ## Codex 入口
+31 -25
View File
@@ -1,11 +1,11 @@
# 自助棋牌室系统开发总纲(V5.7 # 自助棋牌室系统开发总纲(V5.7
> 文档版本:V5.7(中文 Git 提交门禁 + `1a3ea7b` 最新已推送源码校准 + M09-D3 在制收口 + 持续工程开发版) > 文档版本:V5.7(中文 Git 提交门禁 + `60264cb` 最新已推送源码校准 + M09-D4 在制收口 + 持续工程开发版)
> 更新日期:2026-08-11Asia/Shanghai > 更新日期:2026-08-11Asia/Shanghai
> 直接前序版本:V5.6;本文完整继承 V5.6 的全部功能、架构、协议、部署、安全、测试、验收和持续开发要求。 > 直接前序版本:V5.6;本文完整继承 V5.6 的全部功能、架构、协议、部署、安全、测试、验收和持续开发要求。
> 最新已审计提交证据:`1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620`,且接管时 `HEAD == origin/main`;其后的 M09-D3 工作区代码随本次中文工程提交固化。 > 最新已审计提交证据:`60264cbd11e3cbb759d40405ba364238a8a2c559`,且接管时 `HEAD == origin/main`;其后的 M09-D4 工作区代码随本次中文工程提交固化。
> 默认执行起点:保护并继续完成当前 M09-D3 在制工作;通过编译、全量测试、真实 MySQL 往返静态门禁并以中文提交推送后,进入 `M09-D4 → M09-REGRESSION → M10-A/B/C/D → M10-RC1` > 默认执行起点:M09-D4 已通过编译、Windows 全量、真实 MySQL 往返静态门禁并随本次中文工程提交固化;推送校验后进入 `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,18 +15,19 @@
# V5.7 增量控制层 # V5.7 增量控制层
## V5.7-0 M09-D3 收口覆盖层(2026-08-11 最新真实仓库) ## V5.7-0 M09-D4 收口覆盖层(2026-08-11 最新真实仓库)
本节记录 `1a3ea7b + M09-D3 在制工作区` 的真实工程进展,优先于下方审计快照中的 M09-D1 恢复说明、旧 HEAD 和旧游标描述: 本节记录 `60264cb + M09-D4 在制工作区` 的真实工程进展,优先于下方审计快照中的 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 已由 `1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620``feat(M09-D2): 完成商品订单与库存占用生命周期` 中文业务提交闭环并推送,接管时 `HEAD == origin/main` - M09-D2 已由 `1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620``feat(M09-D2): 完成商品订单与库存占用生命周期` 中文业务提交闭环并推送,接管时 `HEAD == origin/main`
- M09-D3 当前工作树已完成寄存记录、寄存明细、不可变状态事件和逐项数量流水 4 张表;来源限定为已完成商品订单或受控员工手工创建。 - M09-D3 已完成寄存记录、寄存明细、不可变状态事件和逐项数量流水 4 张表;来源限定为已完成商品订单或受控员工手工创建。
- 顾客本人或授权员工可分次领取;领取凭证仅保存 SHA-256,部分领取后自动轮换,终态消费,创建和动作重放均不重新泄露已签发原文。行锁、版本 CAS、幂等事件和有限事务重试保证并发不超领。 - 顾客本人或授权员工可分次领取;领取凭证仅保存 SHA-256,部分领取后自动轮换,终态消费,创建和动作重放均不重新泄露已签发原文。行锁、版本 CAS、幂等事件和有限事务重试保证并发不超领。
- 顾客与管理 API、`goods.storage.read/manage`、租户/门店范围、审计和通知 outbox 已接线;M09-D3 静态门禁、后端 build/full test 已通过。 - 顾客与管理 API、`goods.storage.read/manage`、租户/门店范围、审计和通知 outbox 已接线;M09-D3 静态门禁、后端 build/full test 已通过。
- WSL MySQL 8.4.10 的 `up → verify → down → up → verify` 已通过,计数为 217 up、159 verify、199 down;跨租户/跨寄存人拒绝、超量拒绝、部分取出、旧凭证失效、并发全量取出单赢家、取消、过期和不可变流水均有真实数据库证据。 - WSL MySQL 8.4.10 的 `up → verify → down → up → verify` 已通过,计数为 217 up、159 verify、199 down;跨租户/跨寄存人拒绝、超量拒绝、部分取出、旧凭证失效、并发全量取出单赢家、取消、过期和不可变流水均有真实数据库证据。
- M09-D3 工程与本文档伴随记录待`feat(M09-D3): 完成商品寄存与安全取出闭环` 中文业务提交取得 SHA;文档不得虚构尚未产生的提交号 - M09-D3 已由 `60264cbd11e3cbb759d40405ba364238a8a2c559` `feat(M09-D3): 完成商品寄存与安全取出闭环` 中文业务提交闭环并推送
- 唯一执行游标已推进到 `M09-D4`,下一目标为顾客小程序与运营后台商品订单、库存、寄存取出界面。M09-D1/D2/D3 若出现回归只做针对性修复,不得删除重做 - M09-D4 当前工作树已新增登录态顾客目录、小程序选购/商品订单/寄存取出页面,以及按目录、库存、订单、寄存 capability 分域的 Vue 商品运营工作台;一次性领取码只在签发当次页面内存显示
- M09-D4 已通过 Windows 71.6 秒全量与 MySQL 8.4.10 的 217 up、159 verify、199 down 往返;唯一执行游标随本次中文提交推进到 `M09-REGRESSION`。M09-D1/D2/D3/D4 若出现回归只做针对性修复,不得删除重做。
## V5.7-1 本次升级的唯一事实边界 ## V5.7-1 本次升级的唯一事实边界
@@ -7071,14 +7072,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: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620 evidence_head: 60264cbd11e3cbb759d40405ba364238a8a2c559
evidence_origin_main: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620 evidence_origin_main: 60264cbd11e3cbb759d40405ba364238a8a2c559
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-D4 current_stage: M09-REGRESSION
current_stage_status: TODO_AFTER_M09_D3_WORKTREE_CLOSURE current_stage_status: TODO_AFTER_M09_D4_CURRENT_COMMIT
last_completed_functional_stage: M09-D3 last_completed_functional_stage: M09-D4
last_engineering_commit: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620 last_engineering_commit: 60264cbd11e3cbb759d40405ba364238a8a2c559
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
@@ -7093,13 +7094,19 @@ execution_cursor_v57:
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
m09_d3_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT m09_d3_business_commit: 60264cbd11e3cbb759d40405ba364238a8a2c559
m09_d3_static_gate: PASS m09_d3_static_gate: PASS
m09_d3_backend_build_and_full_test: PASS m09_d3_backend_build_and_full_test: PASS
m09_d3_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN m09_d3_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
m09_d3_one_time_credential: PASS m09_d3_one_time_credential: PASS
m09_d3_concurrent_retrieval: PASS m09_d3_concurrent_retrieval: PASS
default_next_stage: M09-REGRESSION m09_d4_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
m09_d4_static_gate: PASS
m09_d4_windows_full_test: PASS_71_6_SECONDS
m09_d4_admin_build_and_test: PASS
m09_d4_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
m09_d4_one_time_credential_not_persisted: PASS
default_next_stage: M10-A
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
@@ -7113,13 +7120,12 @@ execution_cursor_v57:
auto_continue_until_code_complete: true auto_continue_until_code_complete: true
``` ```
真实 `D:\qipai` 若有 `1a3ea7b` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-D3 的推进依据是寄存取出工程、迁移、测试与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交尚未完成,先保护并提交当前工作树,再继续 M09-D4 真实 `D:\qipai` 若有 `60264cb` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-D4 的推进依据是顾客/运营工程、静态门禁、Windows 全量与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交完成后进入 M09-REGRESSION
## 30.2 最终唯一队列 ## 30.2 最终唯一队列
```text ```text
M09-D4 M09-REGRESSION
→ M09-REGRESSION
→ M10-A → M10-A
→ M10-B → M10-B
→ M10-C → M10-C
@@ -7130,13 +7136,13 @@ M09-D4
→ PRODUCTION_ACCEPTED → PRODUCTION_ACCEPTED
``` ```
Git 中文门禁和 M09-D1/D2 已由真实工程提交闭环;M09-D3 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M09-D4。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。 Git 中文门禁和 M09-D1/D2/D3 已由真实工程提交闭环;M09-D4 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M09-REGRESSION。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
M09-D3 的唯一允许推进条件: M09-D4 的唯一允许推进条件:
```text ```text
M09_D3_WORKTREE_PROTECTED=PASS M09_D4_WORKTREE_PROTECTED=PASS
M09_D3_STATIC_GATE=PASS M09_D4_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
@@ -7147,7 +7153,7 @@ PUSH_VERIFIED=PASS
REMOTE_COMMIT_SUBJECT_CN=PASS REMOTE_COMMIT_SUBJECT_CN=PASS
``` ```
任一项未通过时保持 `CURRENT_STAGE=M09-D3`,修复后重跑;不能先更新为 DONE 或进入 M09-D4 任一项未通过时保持 `CURRENT_STAGE=M09-D4`,修复后重跑;不能先更新为 DONE 或进入 M09-REGRESSION
## 30.3 每次提交和推送的最终语言门禁 ## 30.3 每次提交和推送的最终语言门禁
@@ -7208,4 +7214,4 @@ USER_FACING_LANGUAGE=ZH_CN
请阅读 V5.7.md,按当前进度继续开发。 请阅读 V5.7.md,按当前进度继续开发。
``` ```
Codex 读取后直接核验真实仓库,保护已完成阶段与未决删除;若 M09-D3 工作树尚未提交,完成 `feat(M09-D3): 完成商品寄存与安全取出闭环` 中文 commit、push 和远端校验,然后从 M09-D4 建立顾客与运营端商品工作台并沿唯一队列持续推进。禁止先生成替代计划文档,禁止只改 Markdown,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。 Codex 读取后直接核验真实仓库,保护已完成阶段与未决删除;若 M09-D4 工作树尚未提交,完成 `feat(M09-D4): 完成商品选购与运营工作台` 中文 commit、push 和远端校验,然后从 M09-REGRESSION 完成商品域全链路对账并沿唯一队列持续推进。禁止先生成替代计划文档,禁止只改 Markdown,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。
+2
View File
@@ -111,6 +111,7 @@ import {
ScrollText, ScrollText,
Sparkles, Sparkles,
Store, Store,
ShoppingBasket,
TicketCheck, TicketCheck,
UsersRound, UsersRound,
WalletCards WalletCards
@@ -158,6 +159,7 @@ const navigationIcons: Record<string, Component> = {
ScrollText, ScrollText,
Sparkles, Sparkles,
Store, Store,
ShoppingBasket,
TicketCheck, TicketCheck,
UsersRound, UsersRound,
WalletCards WalletCards
+83
View File
@@ -36,6 +36,10 @@ import type {
OrderHistoryItem, OrderHistoryItem,
OrderStatus, OrderStatus,
PaymentAuthorizationStatus, PaymentAuthorizationStatus,
ProductListing,
ProductOrder,
ProductStorage,
InventoryStock,
PlatformApplication, PlatformApplication,
ProfitSharingSnapshot, ProfitSharingSnapshot,
PayoutStateFilter, PayoutStateFilter,
@@ -344,6 +348,85 @@ export function getBusinessStatistics(
return request<BusinessStatistics>(session, `/statistics?${params}`); return request<BusinessStatistics>(session, `/statistics?${params}`);
} }
export function listProductListings(session: ApiSession, storeId: string) {
return request<ProductListing[]>(session,
`/stores/${encodeURIComponent(storeId)}/product-listings?includeInactive=true`);
}
export function listInventoryStocks(
session: ApiSession, input: { storeId: string; page?: number; pageSize?: number; search?: string }
) {
const params = new URLSearchParams({
storeId: input.storeId, page: String(input.page || 1), pageSize: String(input.pageSize || 100)
});
if (input.search) params.set('search', input.search);
return request<PageResult<InventoryStock>>(session, `/inventory/stocks?${params}`);
}
export function mutateInventory(
session: ApiSession, skuId: string,
action: 'inbound' | 'adjust' | 'stocktake' | 'loss' | 'policy',
input: Record<string, unknown>
) {
return request<Record<string, unknown>>(session,
`/inventory/stocks/${encodeURIComponent(skuId)}/${action}`,
{ method: action === 'policy' ? 'PUT' : 'POST', body: JSON.stringify(input) });
}
export function listProductOrders(
session: ApiSession, input: { storeId: string; page?: number; pageSize?: number; status?: string }
) {
const params = new URLSearchParams({
storeId: input.storeId, page: String(input.page || 1), pageSize: String(input.pageSize || 50)
});
if (input.status) params.set('status', input.status);
return request<PageResult<ProductOrder>>(session, `/product-orders?${params}`);
}
export function getProductOrder(session: ApiSession, orderId: string) {
return request<ProductOrder>(session, `/product-orders/${encodeURIComponent(orderId)}`);
}
export function runProductOrderAction(
session: ApiSession, orderId: string,
input: { requestId: string; action: string; reason: string }
) {
return request<ProductOrder>(session, `/product-orders/${encodeURIComponent(orderId)}/actions`, {
method: 'POST', body: JSON.stringify(input)
});
}
export function listProductStorages(
session: ApiSession, input: { storeId: string; page?: number; pageSize?: number; status?: string }
) {
const params = new URLSearchParams({
storeId: input.storeId, page: String(input.page || 1), pageSize: String(input.pageSize || 50)
});
if (input.status) params.set('status', input.status);
return request<PageResult<ProductStorage>>(session, `/product-storages?${params}`);
}
export function getProductStorage(session: ApiSession, storageId: string) {
return request<ProductStorage>(session, `/product-storages/${encodeURIComponent(storageId)}`);
}
export function createOrderProductStorage(
session: ApiSession, input: { requestId: string; sourceType: 'ORDER'; sourceOrderId: string; expiresAt: string }
) {
return request<ProductStorage>(session, '/product-storages', {
method: 'POST', body: JSON.stringify(input)
});
}
export function runProductStorageAction(
session: ApiSession, storageId: string, action: 'retrieve' | 'credential/rotate' | 'cancel',
input: Record<string, unknown>
) {
return request<ProductStorage>(session,
`/product-storages/${encodeURIComponent(storageId)}/${action}`,
{ method: 'POST', body: JSON.stringify(input) });
}
async function request<T>( async function request<T>(
session: ApiSession, session: ApiSession,
path: string, path: string,
@@ -0,0 +1,183 @@
<template>
<section class="goods-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="goods-metrics">
<span><small>门店商品</small><strong>{{ listings.length }}</strong></span>
<span><small>低库存</small><strong>{{ lowStockCount }}</strong></span>
<span><small>待处理订单</small><strong>{{ pendingOrderCount }}</strong></span>
<span><small>有效寄存</small><strong>{{ activeStorageCount }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar goods-toolbar">
<div><p class="section-kicker">M09-D4 · 商品运营</p><h3>商品库存订单与寄存</h3></div>
<div class="toolbar-actions">
<el-select v-model="storeId" filterable placeholder="选择授权门店" @change="changeStore">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-button :icon="RefreshCw" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="商品目录" name="catalog" :disabled="!canCatalogRead">
<el-table v-loading="loading" :data="listings" row-key="id" class="data-table">
<el-table-column label="商品" min-width="220"><template #default="{ row }"><div class="stack"><strong>{{ row.productName }}</strong><span>{{ row.productCode }} · {{ row.categoryName }}</span></div></template></el-table-column>
<el-table-column label="履约" width="150"><template #default="{ row }"><el-tag effect="plain">{{ fulfillmentLabel(row.fulfillmentMode) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '上架' : '下架' }}</el-tag></template></el-table-column>
<el-table-column label="售卖时段" min-width="230"><template #default="{ row }">{{ rangeText(row.salesStartAt, row.salesEndAt) }}</template></el-table-column>
<el-table-column prop="version" label="版本" width="90" />
</el-table>
<el-empty v-if="!loading && !listings.length" description="当前门店暂无商品上架配置" />
</el-tab-pane>
<el-tab-pane :label="`库存 (${stocks.total})`" name="inventory" :disabled="!canInventoryRead">
<div class="tab-tools"><el-input v-model="stockSearch" clearable placeholder="搜索商品或 SKU" @keyup.enter="loadStocks" /><el-button @click="loadStocks">查询</el-button></div>
<el-table v-loading="loading" :data="stocks.items" row-key="id" class="data-table">
<el-table-column label="商品 / SKU" min-width="230"><template #default="{ row }"><div class="stack"><strong>{{ row.productName }} · {{ row.skuName }}</strong><span>{{ row.productCode }} / {{ row.skuCode }}</span></div></template></el-table-column>
<el-table-column label="策略" width="120"><template #default="{ row }"><el-tag effect="plain">{{ row.policyType }}</el-tag></template></el-table-column>
<el-table-column prop="availableQuantity" label="可用" width="90" />
<el-table-column prop="lockedQuantity" label="锁定" width="90" />
<el-table-column prop="lossQuantity" label="损耗" width="90" />
<el-table-column label="预警" width="100"><template #default="{ row }"><el-tag :type="row.lowStock ? 'danger' : 'success'">{{ row.lowStock ? '低库存' : '正常' }}</el-tag></template></el-table-column>
<el-table-column v-if="canInventoryWrite" label="操作" min-width="245" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="inventoryQuantity(row, 'inbound')">入库</el-button><el-button size="small" @click="inventoryQuantity(row, 'loss')">损耗</el-button><el-button size="small" @click="stocktake(row)">盘点</el-button></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`商品订单 (${orders.total})`" name="orders" :disabled="!canOrderRead">
<div class="tab-tools"><el-select v-model="orderStatus" clearable placeholder="全部状态" @change="loadOrders"><el-option v-for="status in orderStatuses" :key="status" :label="statusLabel(status)" :value="status" /></el-select><el-button @click="loadOrders">刷新订单</el-button></div>
<el-table v-loading="loading" :data="orders.items" row-key="id" class="data-table">
<el-table-column label="订单" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ row.orderNo }}</strong><span>会员 #{{ row.memberId || '-' }} · {{ dateText(row.createdAt) }}</span></div></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="orderStatusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="履约" width="130"><template #default="{ row }">{{ fulfillmentLabel(row.fulfillmentMode) }}</template></el-table-column>
<el-table-column label="数量 / 金额" min-width="150"><template #default="{ row }">{{ row.totalQuantity }} · {{ money(row.totalAmountCents) }}</template></el-table-column>
<el-table-column label="操作" min-width="300" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showOrder(row)">详情</el-button><template v-if="canOrderWrite"><el-button v-for="action in orderActions(row)" :key="action" size="small" type="primary" plain @click="actOnOrder(row, action)">{{ actionLabel(action) }}</el-button></template></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`寄存 (${storages.total})`" name="storages" :disabled="!canStorageRead">
<div class="tab-tools"><el-select v-model="storageStatus" clearable placeholder="全部状态" @change="loadStorages"><el-option v-for="status in storageStatuses" :key="status" :label="storageStatusLabel(status)" :value="status" /></el-select><el-button v-if="canStorageWrite" type="primary" @click="openCreateStorage">从订单寄存</el-button><el-button @click="loadStorages">刷新寄存</el-button></div>
<el-table v-loading="loading" :data="storages.items" row-key="id" class="data-table">
<el-table-column label="寄存单" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.storageNo }}</strong><span>会员 #{{ row.memberId }} · {{ row.sourceType === 'ORDER' ? `订单 #${row.sourceOrderId}` : '员工手工创建' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="storageStatusType(row.status)">{{ storageStatusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="数量" width="130"><template #default="{ row }">{{ row.remainingQuantity }} / {{ row.totalQuantity }}</template></el-table-column>
<el-table-column label="到期" min-width="165"><template #default="{ row }">{{ dateText(row.expiresAt) }}</template></el-table-column>
<el-table-column label="凭证" width="120"><template #default="{ row }"> {{ row.claimCredentialVersion }} </template></el-table-column>
<el-table-column label="操作" min-width="320" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showStorage(row)">详情</el-button><template v-if="canStorageWrite && isStorageActive(row.status)"><el-button size="small" type="primary" plain @click="openRetrieve(row)">取出</el-button><el-button size="small" @click="rotateStorage(row)">换码</el-button><el-button size="small" type="danger" plain @click="cancelStorage(row)">取消</el-button></template></div></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-drawer v-model="detailDrawer" :title="detailTitle" size="min(720px, 96vw)">
<template v-if="selectedOrder"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ selectedOrder.orderNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selectedOrder.status) }}</el-descriptions-item><el-descriptions-item label="会员">#{{ selectedOrder.memberId }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(selectedOrder.totalAmountCents) }}</el-descriptions-item></el-descriptions><el-table :data="selectedOrder.items || []"><el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" /><el-table-column prop="quantity" label="数量" width="80" /><el-table-column label="小计" width="110"><template #default="{ row }">{{ money(row.subtotalCents) }}</template></el-table-column></el-table></template>
<template v-if="selectedStorage"><el-descriptions :column="2" border><el-descriptions-item label="寄存单">{{ selectedStorage.storageNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ storageStatusLabel(selectedStorage.status) }}</el-descriptions-item><el-descriptions-item label="寄存人">#{{ selectedStorage.memberId }}</el-descriptions-item><el-descriptions-item label="到期">{{ dateText(selectedStorage.expiresAt) }}</el-descriptions-item></el-descriptions><el-table :data="selectedStorage.items || []"><el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" /><el-table-column prop="totalQuantity" label="寄存" width="80" /><el-table-column prop="remainingQuantity" label="剩余" width="80" /></el-table><h4>取出流水</h4><el-table :data="selectedStorage.movements || []"><el-table-column prop="storageItemId" label="明细" /><el-table-column prop="quantity" label="取出" width="80" /><el-table-column prop="remainingAfter" label="取出后" width="90" /></el-table></template>
</el-drawer>
<el-dialog v-model="retrieveDialog" title="核验凭证并取出商品" width="min(560px, 94vw)"><el-alert title="领取码只用于本次核验;后台不会读取或展示数据库中的凭证哈希。" type="warning" show-icon :closable="false" /><el-form label-position="top"><el-form-item label="寄存明细"><el-select v-model="retrieveForm.storageItemId"><el-option v-for="item in retrieveStorage?.items || []" :key="item.id" :label="`${item.productName} · ${item.skuName}(剩余 ${item.remainingQuantity}`" :value="item.id" /></el-select></el-form-item><el-form-item label="取出数量"><el-input-number v-model="retrieveForm.quantity" :min="1" :max="selectedRetrieveItem?.remainingQuantity || 1" /></el-form-item><el-form-item label="一次性领取码"><el-input v-model="retrieveForm.credential" type="password" show-password autocomplete="off" /></el-form-item></el-form><template #footer><el-button @click="retrieveDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitRetrieve">确认取出</el-button></template></el-dialog>
<el-dialog v-model="createStorageDialog" title="从已完成订单创建寄存" width="min(520px, 94vw)"><el-form label-position="top"><el-form-item label="商品订单 ID"><el-input v-model="createStorageForm.orderId" /></el-form-item><el-form-item label="到期时间"><el-date-picker v-model="createStorageForm.expiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss.SSSZ" /></el-form-item></el-form><template #footer><el-button @click="createStorageDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreateStorage">创建寄存</el-button></template></el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { RefreshCw } from '@lucide/vue';
import {
ApiError, createOrderProductStorage, getProductOrder, getProductStorage,
listInventoryStocks, listManagedStores, listProductListings, listProductOrders,
listProductStorages, mutateInventory, runProductOrderAction, runProductStorageAction,
type ApiSession
} from '../api';
import { adminSessionState } from '../session';
import type { InventoryStock, ManagedStore, PageResult, ProductListing, ProductOrder, ProductStorage } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const storeId = ref(''); const activeTab = ref('catalog');
const listings = ref<ProductListing[]>([]); const stocks = reactive<PageResult<InventoryStock>>({ items: [], total: 0, page: 1, pageSize: 100 });
const orders = reactive<PageResult<ProductOrder>>({ items: [], total: 0, page: 1, pageSize: 50 });
const storages = reactive<PageResult<ProductStorage>>({ items: [], total: 0, page: 1, pageSize: 50 });
const loading = ref(false); const saving = ref(false); const lastError = ref(''); const stockSearch = ref('');
const orderStatus = ref(''); const storageStatus = ref(''); const detailDrawer = ref(false);
const selectedOrder = ref<ProductOrder | null>(null); const selectedStorage = ref<ProductStorage | null>(null);
const retrieveDialog = ref(false); const retrieveStorage = ref<ProductStorage | null>(null);
const retrieveForm = reactive({ storageItemId: '', quantity: 1, credential: '' });
const createStorageDialog = ref(false); const createStorageForm = reactive({ orderId: '', expiresAt: '' });
const orderStatuses = ['PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED'];
const storageStatuses = ['STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED'];
const lowStockCount = computed(() => stocks.items.filter((item) => item.lowStock).length);
const pendingOrderCount = computed(() => orders.items.filter((item) => ['PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'REFUNDING'].includes(item.status)).length);
const activeStorageCount = computed(() => storages.items.filter((item) => isStorageActive(item.status)).length);
const hasAccess = (...required: string[]) => adminSessionState.access.roles.includes('PLATFORM_ADMIN')
|| adminSessionState.access.capabilities.some((item) => [...required, 'tenant.manage', 'platform.manage'].includes(item));
const canCatalogRead = computed(() => hasAccess('product.catalog.read', 'product.catalog.write'));
const canInventoryRead = computed(() => hasAccess('inventory.read', 'inventory.adjust'));
const canInventoryWrite = computed(() => hasAccess('inventory.adjust'));
const canOrderRead = computed(() => hasAccess('goods.order.read', 'goods.order.manage'));
const canOrderWrite = computed(() => hasAccess('goods.order.manage'));
const canStorageRead = computed(() => hasAccess('goods.storage.read', 'goods.storage.manage'));
const canStorageWrite = computed(() => hasAccess('goods.storage.manage'));
const selectedRetrieveItem = computed(() => retrieveStorage.value?.items?.find((item) => item.id === retrieveForm.storageItemId));
const detailTitle = computed(() => selectedOrder.value ? `商品订单 ${selectedOrder.value.orderNo}` : selectedStorage.value ? `寄存单 ${selectedStorage.value.storageNo}` : '业务详情');
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); storeId.value ||= stores.value[0]?.id || ''; activeTab.value = canCatalogRead.value ? 'catalog' : canInventoryRead.value ? 'inventory' : canOrderRead.value ? 'orders' : 'storages'; await loadAll(); } catch { /* displayed */ } finally { loading.value = false; } }
async function changeStore() { await loadAll(); }
async function loadAll() { if (!storeId.value) return; loading.value = true; try { const [nextListings, nextStocks, nextOrders, nextStorages] = await Promise.all([canCatalogRead.value ? capture(() => listProductListings(props.session, storeId.value)) : Promise.resolve([] as ProductListing[]), canInventoryRead.value ? capture(() => listInventoryStocks(props.session, { storeId: storeId.value, pageSize: 100 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 100 } as PageResult<InventoryStock>), canOrderRead.value ? capture(() => listProductOrders(props.session, { storeId: storeId.value, pageSize: 50 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 50 } as PageResult<ProductOrder>), canStorageRead.value ? capture(() => listProductStorages(props.session, { storeId: storeId.value, pageSize: 50 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 50 } as PageResult<ProductStorage>)]); listings.value = nextListings; Object.assign(stocks, nextStocks); Object.assign(orders, nextOrders); Object.assign(storages, nextStorages); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadStocks() { if (!storeId.value || !canInventoryRead.value) return; loading.value = true; try { Object.assign(stocks, await capture(() => listInventoryStocks(props.session, { storeId: storeId.value, pageSize: 100, search: stockSearch.value.trim() || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadOrders() { if (!storeId.value || !canOrderRead.value) return; loading.value = true; try { Object.assign(orders, await capture(() => listProductOrders(props.session, { storeId: storeId.value, pageSize: 50, status: orderStatus.value || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadStorages() { if (!storeId.value || !canStorageRead.value) return; loading.value = true; try { Object.assign(storages, await capture(() => listProductStorages(props.session, { storeId: storeId.value, pageSize: 50, status: storageStatus.value || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function inventoryQuantity(row: InventoryStock, action: 'inbound' | 'loss') { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入${action === 'inbound' ? '入库' : '损耗'}数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^[1-9]\d{0,8}$/, inputErrorMessage: '请输入正整数' }); await capture(() => mutateInventory(props.session, row.skuId, action, { storeId: storeId.value, requestId: requestId(`admin-${action}`), reason: action === 'inbound' ? '运营后台商品入库' : '运营后台登记损耗', expectedVersion: row.version, quantity: Number(value) })); ElMessage.success('库存已更新'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function stocktake(row: InventoryStock) { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入盘点后的可用数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^\d{1,9}$/, inputErrorMessage: '请输入非负整数' }); await capture(() => mutateInventory(props.session, row.skuId, 'stocktake', { storeId: storeId.value, requestId: requestId('admin-stocktake'), reason: '运营后台库存盘点', expectedVersion: row.version, availableQuantity: Number(value), lossQuantity: row.lossQuantity })); ElMessage.success('盘点已完成'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function showOrder(row: ProductOrder) { try { selectedStorage.value = null; selectedOrder.value = await capture(() => getProductOrder(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
function orderActions(row: ProductOrder) { if (row.status === 'PAID') return ['ACCEPT', 'REFUND']; if (row.status === 'ACCEPTED') return row.fulfillmentMode === 'DELIVERY' ? ['START_DELIVERY', 'CANCEL'] : ['MARK_READY', 'CANCEL']; if (['DELIVERING', 'READY_FOR_SELF_SERVICE'].includes(row.status)) return ['COMPLETE']; if (row.status === 'COMPLETED') return ['REFUND']; if (row.status === 'PENDING_PAYMENT') return ['CANCEL']; return []; }
async function actOnOrder(row: ProductOrder, action: string) { try { const destructive = ['CANCEL', 'REFUND'].includes(action); let reason = ''; if (destructive) { const result = await ElMessageBox.prompt(`将对订单 ${row.orderNo} 执行“${actionLabel(action)}”,库存与退款状态会按当前履约阶段处理。请输入原因。`, '确认商品订单操作', { inputPattern: /\S{2,512}/, inputErrorMessage: '请填写至少 2 个字符的原因', type: 'warning' }); reason = result.value.trim(); } await capture(() => runProductOrderAction(props.session, row.id, { requestId: requestId(`order-${action.toLowerCase()}`), action, reason })); ElMessage.success('订单状态已更新'); await loadOrders(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function showStorage(row: ProductStorage) { try { selectedOrder.value = null; selectedStorage.value = await capture(() => getProductStorage(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
async function openRetrieve(row: ProductStorage) { try { retrieveStorage.value = await capture(() => getProductStorage(props.session, row.id)); const first = retrieveStorage.value.items?.find((item) => item.remainingQuantity > 0); Object.assign(retrieveForm, { storageItemId: first?.id || '', quantity: 1, credential: '' }); retrieveDialog.value = true; } catch { /* displayed */ } }
async function submitRetrieve() { if (!retrieveStorage.value || !retrieveForm.storageItemId || !retrieveForm.credential.trim()) return ElMessage.warning('请填写寄存明细、数量和领取码'); saving.value = true; try { const result = await capture(() => runProductStorageAction(props.session, retrieveStorage.value!.id, 'retrieve', { requestId: requestId('storage-retrieve'), claimCredential: retrieveForm.credential.trim(), items: [{ storageItemId: retrieveForm.storageItemId, quantity: retrieveForm.quantity }] })); retrieveDialog.value = false; retrieveForm.credential = ''; if (result.nextClaimCredential) await showOneTimeCredential(result.nextClaimCredential, '部分取出已完成,旧领取码已失效'); else ElMessage.success('商品已取出'); await loadStorages(); } catch { /* displayed */ } finally { saving.value = false; } }
async function rotateStorage(row: ProductStorage) { try { await ElMessageBox.confirm(`寄存单 ${row.storageNo} 的当前领取码会立即失效,新码只显示一次。`, '确认轮换领取码', { type: 'warning' }); const result = await capture(() => runProductStorageAction(props.session, row.id, 'credential/rotate', { requestId: requestId('storage-rotate') })); if (result.claimCredential) await showOneTimeCredential(result.claimCredential, '领取码已轮换'); await loadStorages(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function cancelStorage(row: ProductStorage) { try { const { value } = await ElMessageBox.prompt(`取消寄存单 ${row.storageNo} 后不可继续领取,剩余 ${row.remainingQuantity} 件不会自动回库存。请输入原因。`, '确认取消寄存', { inputPattern: /\S{2,512}/, inputErrorMessage: '请填写至少 2 个字符的原因', type: 'warning' }); await capture(() => runProductStorageAction(props.session, row.id, 'cancel', { requestId: requestId('storage-cancel'), reason: value.trim() })); ElMessage.success('寄存已取消'); await loadStorages(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
function openCreateStorage() { Object.assign(createStorageForm, { orderId: '', expiresAt: new Date(Date.now() + 30 * 86400000).toISOString() }); createStorageDialog.value = true; }
async function submitCreateStorage() { if (!/^\d+$/.test(createStorageForm.orderId) || !createStorageForm.expiresAt) return ElMessage.warning('请填写已完成商品订单 ID 和到期时间'); saving.value = true; try { const result = await capture(() => createOrderProductStorage(props.session, { requestId: requestId('storage-create'), sourceType: 'ORDER', sourceOrderId: createStorageForm.orderId, expiresAt: new Date(createStorageForm.expiresAt).toISOString() })); createStorageDialog.value = false; if (result.claimCredential) await showOneTimeCredential(result.claimCredential, '寄存已创建'); await loadStorages(); } catch { /* displayed */ } finally { saving.value = false; } }
async function showOneTimeCredential(value: string, title: string) { await ElMessageBox.alert(`领取码:${value}\n\n请立即交给寄存人安全保存。关闭后后台无法再次查看原码,可通过“换码”签发新码。`, title, { confirmButtonText: '我已安全记录', type: 'success', dangerouslyUseHTMLString: false }); }
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }
function money(value: number) { return `${(Number(value || 0) / 100).toFixed(2)}`; }
function dateText(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
function rangeText(start: string | null, end: string | null) { return start || end ? `${dateText(start)}${dateText(end)}` : '长期有效'; }
function fulfillmentLabel(value: string) { return ({ DELIVERY: '房间配送', SELF_SERVICE: '自助领取', BOTH: '配送 / 自助' } as Record<string, string>)[value] || value; }
function statusLabel(value: string) { return ({ PENDING_PAYMENT: '待支付', PAID: '已支付', ACCEPTED: '已接单', DELIVERING: '配送中', READY_FOR_SELF_SERVICE: '待自取', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退款中', REFUNDED: '已退款', REFUND_FAILED: '退款失败' } as Record<string, string>)[value] || value; }
function actionLabel(value: string) { return ({ ACCEPT: '接单', START_DELIVERY: '开始配送', MARK_READY: '标记待取', COMPLETE: '完成', CANCEL: '取消', REFUND: '退款' } as Record<string, string>)[value] || value; }
function orderStatusType(value: string) { return ['COMPLETED', 'REFUNDED'].includes(value) ? 'success' : ['CANCELLED', 'REFUND_FAILED'].includes(value) ? 'danger' : ['PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'REFUNDING'].includes(value) ? 'warning' : 'info'; }
function storageStatusLabel(value: string) { return ({ STORED: '已寄存', PARTIALLY_RETRIEVED: '部分取出', RETRIEVED: '已取完', EXPIRED: '已过期', CANCELLED: '已取消' } as Record<string, string>)[value] || value; }
function storageStatusType(value: string) { return value === 'RETRIEVED' ? 'success' : value === 'PARTIALLY_RETRIEVED' ? 'warning' : ['EXPIRED', 'CANCELLED'].includes(value) ? 'danger' : 'primary'; }
function isStorageActive(value: string) { return ['STORED', 'PARTIALLY_RETRIEVED'].includes(value); }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
<style scoped>
.goods-page { display: grid; gap: 18px; }
.goods-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.goods-metrics span { display: grid; gap: 5px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: #fff; }
.goods-metrics small { color: var(--muted); }
.goods-metrics strong { font-size: 28px; }
.panel { padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: #fff; }
.goods-toolbar, .toolbar-actions, .tab-tools, .row-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.goods-toolbar { justify-content: space-between; margin-bottom: 12px; }
.goods-toolbar h3 { margin: 2px 0 0; }
.section-kicker { margin: 0; color: #2563eb; font-size: 12px; font-weight: 700; }
.tab-tools { justify-content: flex-end; margin-bottom: 14px; }
.tab-tools .el-input { width: min(320px, 100%); }
.stack { display: grid; gap: 4px; }
.stack span { color: var(--muted); font-size: 12px; }
h4 { margin: 22px 0 10px; }
@media (max-width: 760px) {
.goods-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.panel { padding: 12px; }
.goods-toolbar { align-items: stretch; }
.toolbar-actions, .toolbar-actions .el-select, .toolbar-actions .el-button { width: 100%; }
.tab-tools { justify-content: stretch; }
.tab-tools > * { flex: 1 1 160px; }
}
</style>
+1
View File
@@ -2,6 +2,7 @@ export type AdminMenuKey =
| 'overview' | 'overview'
| 'stores' | 'stores'
| 'orders' | 'orders'
| 'products'
| 'payments' | 'payments'
| 'thirdParty' | 'thirdParty'
| 'people' | 'people'
+1
View File
@@ -2,6 +2,7 @@ export const ADMIN_NAVIGATION = Object.freeze([
{ menuKey: 'overview', path: '/overview', stage: 'M08-D', title: '平台运营总览', label: '运营总览', icon: 'LayoutDashboard', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }, { menuKey: 'overview', path: '/overview', stage: 'M08-D', title: '平台运营总览', label: '运营总览', icon: 'LayoutDashboard', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }, { menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }, { menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'products', path: '/products', stage: 'M09-D4', title: '商品、库存、订单与寄存', label: '商品运营', icon: 'ShoppingBasket', capabilities: ['product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust', 'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }, { menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }, { menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }, { menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
+1
View File
@@ -11,6 +11,7 @@ const routeComponents = {
overview: () => import('./components/OperationsOverviewPanel.vue'), overview: () => import('./components/OperationsOverviewPanel.vue'),
stores: () => import('./components/StoresRoomsPanel.vue'), stores: () => import('./components/StoresRoomsPanel.vue'),
orders: () => import('./components/OrdersPanel.vue'), orders: () => import('./components/OrdersPanel.vue'),
products: () => import('./components/ProductOperationsPanel.vue'),
payments: () => import('./components/PaymentsPanel.vue'), payments: () => import('./components/PaymentsPanel.vue'),
thirdParty: () => import('./components/ThirdPartyPanel.vue'), thirdParty: () => import('./components/ThirdPartyPanel.vue'),
people: () => import('./components/MembersStaffPanel.vue'), people: () => import('./components/MembersStaffPanel.vue'),
+45
View File
@@ -22,6 +22,51 @@ export interface PageResult<T> {
pageSize: number; pageSize: number;
} }
export interface ProductListing {
id: string; storeId: string; productId: string; categoryId: string;
categoryName: string; productCode: string; productName: string;
status: 'ACTIVE' | 'INACTIVE'; fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE' | 'BOTH';
salesStartAt: string | null; salesEndAt: string | null; version: number;
}
export interface InventoryStock {
id: string; storeId: string; productId: string; productCode: string; productName: string;
skuId: string; skuCode: string; skuName: string; salePriceCents: number;
policyType: 'TRACKED' | 'UNLIMITED'; availableQuantity: number;
lockedQuantity: number; lossQuantity: number; lowStockThreshold: number;
lowStock: boolean; version: number;
}
export interface ProductOrder {
id: string; storeId: string; memberId: string | null; roomOrderId: string | null;
orderNo: string; fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE'; status: string;
itemCount: number; totalQuantity: number; totalAmountCents: number;
paidAmountCents: number; refundedAmountCents: number; note: string;
inventoryStatus: string; createdAt: string; updatedAt: string;
items?: ProductOrderItem[];
}
export interface ProductOrderItem {
id: string; productName: string; skuName: string; quantity: number;
unitPriceCents: number; subtotalCents: number; note?: string;
}
export interface ProductStorage {
id: string; storeId: string; memberId: string; sourceOrderId: string | null;
storageNo: string; sourceType: 'ORDER' | 'MANUAL'; status: string;
itemCount: number; totalQuantity: number; remainingQuantity: number;
expiresAt: string; claimCredentialVersion: number; claimCredentialConsumed: boolean;
createdAt: string; updatedAt: string; cancelReason: string;
items?: ProductStorageItem[]; events?: Array<Record<string, unknown>>;
movements?: Array<Record<string, unknown>>; claimCredential?: string | null;
nextClaimCredential?: string | null;
}
export interface ProductStorageItem {
id: string; productName: string; skuName: string; skuId: string;
totalQuantity: number; remainingQuantity: number;
}
export interface ManagedStore { export interface ManagedStore {
id: string; id: string;
name: string; name: string;
+10 -1
View File
@@ -9,11 +9,12 @@ import {
assert.deepEqual( assert.deepEqual(
ADMIN_NAVIGATION.map(({ path }) => path), ADMIN_NAVIGATION.map(({ path }) => path),
['/overview', '/stores', '/orders', '/payments', '/third-party', '/people', ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system'] '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
); );
assert.equal(pathForMenu('platformApps'), '/apps'); assert.equal(pathForMenu('platformApps'), '/apps');
assert.equal(pathForMenu('thirdParty'), '/third-party'); assert.equal(pathForMenu('thirdParty'), '/third-party');
assert.equal(pathForMenu('products'), '/products');
const storeOperator = { const storeOperator = {
roles: ['STORE_ADMIN'], roles: ['STORE_ADMIN'],
@@ -24,6 +25,14 @@ assert.equal(resolveAuthorizedPath(storeOperator, 'orders'), '/orders');
assert.equal(resolveAuthorizedPath(storeOperator, 'system'), '/overview'); assert.equal(resolveAuthorizedPath(storeOperator, 'system'), '/overview');
assert.equal(firstAuthorizedPath(storeOperator), '/overview'); assert.equal(firstAuthorizedPath(storeOperator), '/overview');
const goodsOperator = {
roles: ['STAFF'],
capabilities: ['goods.order.read'],
menus: ['products']
};
assert.equal(resolveAuthorizedPath(goodsOperator, 'products'), '/products');
assert.equal(firstAuthorizedPath(goodsOperator), '/products');
const forgedSystemMenu = { const forgedSystemMenu = {
roles: ['STORE_ADMIN'], roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read'], capabilities: ['store.operation.read'],
+4 -2
View File
@@ -113,8 +113,10 @@ function adminAccess(access: AccessProfile) {
const storeRead = tenant || access.capabilities.includes('store.operation.read'); const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [ const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []), ...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
...(tenant || access.capabilities.includes('product.catalog.read') ...(tenant || access.capabilities.some((capability) => [
|| access.capabilities.includes('inventory.read') ? ['products'] : []), 'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage'
].includes(capability)) ? ['products'] : []),
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []), ...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []), ...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : []) ...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
+22 -1
View File
@@ -119,13 +119,34 @@ export interface ProductRouteOptions {
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct' | 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku' | 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
| 'listListings' | 'putListing' | 'archiveListing' | 'listListings' | 'putListing' | 'archiveListing'
| 'getStoreSettings' | 'putStoreSettings'>; | 'getStoreSettings' | 'putStoreSettings' | 'listStoreCatalog'>;
authRepository: Pick<AuthRepository, 'validateSession'>; authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> }; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string; jwtSecret: string;
} }
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) { export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
app.get('/app-api/stores/:storeId/product-catalog', async (request, reply) => {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
const params = storeParamsSchema.safeParse(request.params);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return;
}
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listStoreCatalog({
tenantId: auth.session.tenantId, storeId: params.data.storeId
}),
traceId: request.traceId
}));
});
for (const path of [ for (const path of [
'/admin-api/stores/:storeId/product-categories', '/admin-api/stores/:storeId/product-categories',
'/app-api/management/stores/:storeId/product-categories' '/app-api/management/stores/:storeId/product-categories'
+15
View File
@@ -24,6 +24,9 @@ const record = (method, result) => async (...args) => {
return typeof result === 'function' ? result(...args) : result; return typeof result === 'function' ? result(...args) : result;
}; };
const repository = { const repository = {
listStoreCatalog: record('listStoreCatalog', {
storeId: '11', salesOpen: true, categories: []
}),
listCategories: record('listCategories', []), listCategories: record('listCategories', []),
createCategory: record('createCategory', { categoryId: '31', version: 1 }), createCategory: record('createCategory', { categoryId: '31', version: 1 }),
updateCategory: record('updateCategory', { categoryId: '31', version: 2 }), updateCategory: record('updateCategory', { categoryId: '31', version: 2 }),
@@ -79,6 +82,18 @@ const unauthorized = await app.inject({
assert.equal(unauthorized.statusCode, 401); assert.equal(unauthorized.statusCode, 401);
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID'); assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
assert.equal((await app.inject({
method: 'GET', url: '/app-api/stores/11/product-catalog'
})).statusCode, 401);
const customerCatalog = await app.inject({
method: 'GET', url: '/app-api/stores/11/product-catalog',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(customerCatalog.statusCode, 200);
assert.deepEqual(calls.find((call) => call.method === 'listStoreCatalog').args[0], {
tenantId: '7', storeId: '11'
});
currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] }; currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
const forbidden = await app.inject({ const forbidden = await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` } method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
@@ -0,0 +1,12 @@
# 2026-08-11 M09-D4 商品体验 API
## 新增顾客目录接口
- `GET /app-api/stores/:storeId/product-catalog`
- 要求有效顾客访问令牌;tenantId 仅取自已验证会话,禁止由客户端传入。
- 返回门店当前销售开关、分类、在售商品、履约方式、寄存能力与公开 SKU 售价;复用 M09-D1 的目录仓储,不建立第二套商品数据。
- 未登录返回 401;非法门店参数返回 400;目录业务错误继续使用统一商品错误映射。
## 既有接口的前端接入
M09-D4 未改变商品订单、库存和寄存写契约。小程序复用 `/app-api/product-orders``/app-api/product-storages` 顾客接口;Vue 后台复用 `/admin-api/product-listings``inventory/stocks``product-orders``product-storages` 既有管理接口。后台菜单与页面按 `product.catalog.*``inventory.*``goods.order.*``goods.storage.*` 独立授权,服务端仍是最终权限边界。
+11 -11
View File
@@ -1,10 +1,10 @@
# 当前开发成果基线 # 当前开发成果基线
> V5.0 首次核验日期:2026-06-16 > V5.0 首次核验日期:2026-06-16
> audited_commit: `1a3ea7b` > audited_commit: `60264cb`
> audited_worktree: M09-D3 当前工作树(待本次中文工程提交) > audited_worktree: M09-D4 已完成工程(随本次中文工程提交固化
> audited_at: `2026-08-11T09:30:00+08:00` > audited_at: `2026-08-11T15:10:00+08:00`
> next_engineering_target: 按 V5.7 固定队列执行 M09-D4,接入商品、库存、订单与寄存取出的顾客和运营界面 > next_engineering_target: 按 V5.7 固定队列执行 M09-REGRESSION,完成商品域全链路对账与模块验收
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论 ## 总体结论
@@ -13,9 +13,9 @@
|---|---|---|---| |---|---|---|---|
| 总纲版本 | 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 业务 APIM09-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 待本次中文工程提交取得 | | 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 APIM09-A/B/C 已完成保洁收口,M09-D1/D2/D3 已完成商品目录、库存、订单支付和寄存取出安全生命周期;M09-D4 新增登录态顾客门店目录接口 | `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` | M09-D4 Windows 全量 71.6 秒通过;MySQL 8.4.10 完成 217 up、159 verify、199 down 往返与清理探针,进入 M09-REGRESSION |
| 后台管理端 | 已建立 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 权限路由工程;13 个业务入口按路由懒加载;新增商品运营工作台统一目录、库存商品订单与寄存,并按各域 read/manage capability 独立加载和控制写操作 | `admin/src/router.ts``admin/src/components/ProductOperationsPanel.vue``admin/src/api.ts``backend/src/routes/admin-auth.ts` | 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 | | 微信小程序 | 已纳管微信原生模板,并完成顾客端房间订单、充值、权益、保洁等主体链路;M09-D4 新增门店商品选购、购物车、商品订单取消、整单寄存、部分取出、换码与取消寄存页面 | `miniapp/pages/goods/*``miniapp/pages/store/detail``miniapp/pages/profile/*``miniapp/utils/api.js``scripts/check-m09-d4-product-experience.mjs` | 商品领取码只保留页面内存并可复制后隐藏,不调用本地持久化;生产商品支付入口仍沿用服务端后续真实微信支付接线边界 |
| 数据库迁移 | 已形成 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;一次性凭证、部分取出、并发全量取出单赢家、取消/过期、隔离和不可变流水通过 | | 数据库迁移 | 已形成 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 后接入 |
@@ -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/D3、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前按 V5.7 执行 M09-D4M10 尚未进入。 - M01-M04 与 M07 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`M09-A/B/C/D1/D2/D3/D4、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前进入 M09-REGRESSIONM10 尚未进入。
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。 - 任何“目录存在”只能证明骨架存在,不能证明模块完成。
## Git 历史敏感信息核验 ## Git 历史敏感信息核验
@@ -37,6 +37,6 @@
## 下一步 ## 下一步
1. 执行 M09-D4:在顾客小程序和运营后台接入商品目录、商品订单、库存与寄存/取出工作台,复用服务端权限、状态机与幂等契约 1. 完成 M09-D4 中文提交、push 和远端标题校验
2. 领取凭证只保存哈希,限制本人或授权门店员工领取;分次取出以事务行锁、稳定 requestId 和剩余数量条件阻止重复、超量与并发超领 2. 执行 M09-REGRESSION,对商品目录、库存、订单支付、退款、寄存数量、权限和界面逐单对账,补模块级缺陷与验收证据
3. 寄存、取出、取消和过期同事务写审计与通知 outbox;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。 3. M06-G 真实硬件、真实微信与团购联调继续等待外部条件,不阻断内部主队列。
@@ -0,0 +1,22 @@
# 2026-08-11 M09-D4 商品端到端体验
## 工程结果
- 后端新增登录态顾客门店商品目录接口,租户从已验证会话取得,门店 ID 严格校验;路由测试覆盖未登录 401、登录成功和 repository 租户/门店参数。
- Vue 后台新增第 13 个懒加载“商品运营”路由,将门店商品目录、库存查询与入库/损耗/盘点、商品订单详情与状态处置、寄存详情/取出/换码/取消统一到响应式工作台。
- 工作台将目录、库存、商品订单、寄存拆成独立读写能力,只有相应 capability 才调用该业务域接口或展示写按钮,避免单域员工被其他域 403 阻断整个页面。
- 微信小程序新增商品页,覆盖门店选购、购物车、自助商品订单、取消、已完成订单整单寄存、寄存列表/详情、部分取出、主动换码和取消寄存;门店详情和个人中心均提供入口。
- 领取码只保留在当前页面 data 中,签发当次提示复制并允许立即隐藏;代码不调用 `wx.setStorage`/`setStorageSync` 持久化领取码。部分取出后展示新码并明确旧码失效。
## 验证进度
- 后端 TypeScript build 与全量单元测试:PASS。
- Vue TypeScript 检查、生产构建、导航权限测试:PASS;`ProductOperationsPanel` 独立懒加载分块 gzip 约 7.10 KiB。
- `node scripts/check-m09-d4-product-experience.mjs`:PASS,覆盖顾客目录、后台权限/API/路由、小程序入口与一次性领取码不落盘。
- Windows `scripts/dev/windows/test-all.ps1`:PASS,耗时 71.6 秒;包含仓库完整性、敏感信息、状态文档、后端全测、M09-D1 至 D4 门禁、Vue 生产构建与导航测试。
- WSL 原生临时副本 + MySQL 8.4.10PASS,完成 `up → verify → down → up → verify`217 条 up、159 条 verify、199 条 down;商品目录、库存并发、订单支付/取消/退款和寄存并发用例全部通过。
- MySQL 中断清理探针通过,临时数据库、临时账号和 `/home/f/qipai-wsl-test` 均已清理。
## 阶段状态
`M09-D4=DONE`。工程随 `feat(M09-D4): 完成商品选购与运营工作台` 中文提交固化;唯一执行游标进入 `M09-REGRESSION`
+6 -4
View File
@@ -52,7 +52,9 @@
> 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-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。 > 2026-08-11 M09-D3工程提交 `60264cb` 为 `GOODS-104` 建立商品寄存与安全取出闭环;4 张表保存寄存记录、商品快照、不可变状态事件和逐项数量流水。已完成商品订单或受控员工可创建寄存,本人/授权员工可分次取出;领取凭证只保存 SHA-256、部分取出后单次轮换、终态消费,原文仅在创建/轮换响应出现。取消、过期和取出同事务写审计与 outbox。WSL MySQL 8.4.10 完成 217 up、159 verify、199 down 往返,并验证跨租户/跨寄存人拒绝、超量拒绝和并发全量取出单赢家,游标进入 M09-D4。
> 2026-08-11 M09-D4:当前伴随工程为 `PRD-001`、`INV-001`、`GOODS-102`、`GOODS-104` 接入端到端体验。登录顾客可读取门店目录,在小程序选购、创建/取消商品订单、整单寄存、部分取出、换码和取消寄存;领取码只在签发当次页面内存显示。Vue 后台第 13 个权限路由统一目录、库存、订单和寄存,按各域 capability 独立加载,写操作继续由服务端复验。Windows 全量 71.6 秒与 MySQL 8.4.10 的 217 up、159 verify、199 down 往返通过,游标进入 M09-REGRESSION。
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 | | ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
@@ -79,10 +81,10 @@
| CLN-003 | 保洁统计 | M08-B/M09 | DONE | `1f10715` | 保洁员与管理端统计统一按有效成员份额复算:免清洁不计奖励,DRAFT/CONFIRMED 仍计未付款,只有 PAID 计已结算;负责人/协作者、任务状态、结算状态、门店、日期和每日趋势口径一致。统计页、任务/结算明细和整数分 CSV 可相互校验;真实 MySQL 覆盖协作成员分步付款和最终任务结算。 | - | M10 以生产数据抽样复算,不重开统计状态机。 | | CLN-003 | 保洁统计 | M08-B/M09 | DONE | `1f10715` | 保洁员与管理端统计统一按有效成员份额复算:免清洁不计奖励,DRAFT/CONFIRMED 仍计未付款,只有 PAID 计已结算;负责人/协作者、任务状态、结算状态、门店、日期和每日趋势口径一致。统计页、任务/结算明细和整数分 CSV 可相互校验;真实 MySQL 覆盖协作成员分步付款和最终任务结算。 | - | M10 以生产数据抽样复算,不重开统计状态机。 |
| CLN-004 | 保洁结算 | M09-C | PARTIAL | `1f10715` | 同一任务成员份额由数据库生成唯一键保证最多一条有效结算明细,并发生成只有一个赢家;活动结算冻结成员奖励。取消保留原单/明细并生成负向冲正和状态事件,可重新生成;出款前 CAS、稳定请求号、商户隔离回调、成功重放、失败重试、人工已付、同源列表/导出和整数分均已闭环。协作任务按收款人分别付款,全部有效份额 PAID 后任务才进入 SETTLED。 | 真实微信商户凭据、生产小额转账和回调仍为 BLOCKED_EXTERNAL。 | 取得生产商户条件后执行小额转账、迟到回调和账单对账验收;内部工程不阻断 M09-D1。 | | CLN-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/M09-D4 | DONE | `93af128` + M09-D4 当前暂存工程 | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;M09-D4 新增登录态顾客门店目录,小程序与运营后台复用同一商品主数据。 | - | M09-REGRESSION 对账目录销售时段、价格快照和门店隔离。 |
| INV-001 | 库存策略与不可变流水 | M09-D1/M09-D2 | DONE | `93af128``1a3ea7b` | 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 | `1a3ea7b` | 商品订单与支付单分离订单项固化商品、SKU、规格、单价、数量和金额快照,状态事件不可变。顾客可下单、查询、取消,授权 STAFF/管理员可接单、配送/待取、完成、取消/退款;房间配送验证本人有效房间订单,自助订单不绑定房间。支付成功扣减、取消/超时释放、退款按履约状态返库均有回调幂等事务重试、审计和 MySQL 8.4 并发证据。 | - | M09-D4 接入顾客和运营端商品订单界面。 | | GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b` + M09-D4 当前暂存工程 | 商品订单与支付单分离订单项与状态事件不可变;M09-D4 小程序接入选购、购物车、创建与取消,Vue 后台接入筛选、详情、接单、配送/待取、完成、取消退款。库存、支付、退款仍由服务端状态机和幂等事务控制。 | 真实微信商品支付入口仍缺生产商户条件。 | M09-REGRESSION 完成模拟支付全链路对账;真实支付纳入 BLOCKED_EXTERNAL。 |
| GOODS-104 | 商品寄存与安全取出 | M09-D3 | DONE | M09-D3 当前工作树(待本次中文工程提交) | 支持已完成商品订单或授权员工手工创建寄存状态覆盖 STORED、PARTIALLY_RETRIEVED、RETRIEVED、EXPIRED、CANCELLED;商品/SKU/数量/会员/门店/到期时间固化。领取凭证仅存 SHA-256,部分取出自动轮换且旧凭证失效;本人或授权员工可领取,逐项数量流水不可变。并发领取行锁、版本 CAS、幂等事件和事务重试保证不超领,所有状态动作写审计与 outbox。 | - | M09-D4 接入顾客寄存详情、凭证操作与运营领取工作台。 | | GOODS-104 | 商品寄存与安全取出 | M09-D3/M09-D4 | DONE | `60264cb` + M09-D4 当前暂存工程 | 寄存状态、商品快照、一次性哈希领取码、部分取出自动轮换、并发防超领、审计与 outbox 已由 M09-D3 完成;M09-D4 接入顾客寄存列表/详情/取出/换码/取消和运营领取工作台,原码不写本地持久化。 | - | M09-REGRESSION 对账订单来源、剩余数量、凭证版本和不可变流水。 |
| 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 起业务资源继承门店范围。 |
+7 -7
View File
@@ -3,16 +3,16 @@
```yaml ```yaml
execution_cursor: execution_cursor:
current_module: M09 current_module: M09
current_stage: M09-D4 current_stage: M09-REGRESSION
stage_status: TODO stage_status: TODO
last_completed_stage: M09-D3 last_completed_stage: M09-D4
next_stage: M09-REGRESSION next_stage: M10-A
last_engineering_commit: `1a3ea7b` last_engineering_commit: `60264cb`
pending_engineering_commit: "M09-D3 当前工作树(待本次中文工程提交)" pending_engineering_commit: "M09-D4 已完成工程(随本次中文工程提交固化"
last_push_verified: true last_push_verified: true
base_branch: main base_branch: main
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNALM08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。" blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNALM08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
updated_at: 2026-08-11T09:30:00+08:00 updated_at: 2026-08-11T15:10: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-D3 当前工作树(待本次中文工程提交) | docs/devlogs/2026-08-11-M09-D3-商品寄存与安全取出.md | M09-A/B/C 已完成保洁收口;M09-D1/D2 完成商品、库存、订单和支付库存生命周期,M09-D3 完成寄存单、分次取出流水、一次性哈希凭证、本人/授权员工领取、取消/过期 outbox、RBAC 和真实 MySQL 并发防超领回归。当前进入 M09-D4,接入顾客与运营界面。 | | M09 保洁任务、结算、商品和库存 | DOING | `60264cb` + M09-D4 已完成工程 | docs/devlogs/2026-08-11-M09-D4-商品端到端体验.md | M09-A/B/C M09-D1/D2/D3 已完成并推送。M09-D4 已接入登录态顾客目录、小程序选购/商品订单/寄存取出闭环,以及按目录、库存、订单、寄存权限分域的 Vue 运营工作台;Windows 全量和 MySQL 8.4 往返通过,游标进入 M09-REGRESSION。 |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
+1
View File
@@ -2,6 +2,7 @@
"pages": [ "pages": [
"pages/index/index", "pages/index/index",
"pages/store/detail", "pages/store/detail",
"pages/goods/index",
"pages/room/detail", "pages/room/detail",
"pages/orders/list", "pages/orders/list",
"pages/orders/detail", "pages/orders/detail",
+323
View File
@@ -0,0 +1,323 @@
const { request, ensureLogin, cents, clientRequestId } = require('../../utils/api.js')
const ACTIVE_STORAGE = ['STORED', 'PARTIALLY_RETRIEVED']
Page({
data: {
storeId: '', activeTab: 'catalog', loading: false, submitting: false,
errorMessage: '', catalog: { salesOpen: false, categories: [] },
cartItems: [], cartQuantity: 0, cartTotalText: cents(0),
orders: [], storages: [], selectedStorage: null,
oneTimeCredential: '', oneTimeCredentialTitle: '',
},
onLoad(options) {
this.setData({
storeId: options.storeId || '',
activeTab: ['catalog', 'orders', 'storages'].includes(options.tab) ? options.tab : 'catalog',
})
this.loadAll()
},
onShow() {
if (this.data.orders.length || this.data.storages.length) this.loadBusinessLists()
},
async loadAll() {
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
const jobs = [
request('/product-orders?page=1&pageSize=50'),
request('/product-storages?page=1&pageSize=50'),
]
if (this.data.storeId) {
jobs.push(request(`/stores/${encodeURIComponent(this.data.storeId)}/product-catalog`))
}
const [ordersResponse, storagesResponse, catalogResponse] = await Promise.all(jobs)
this.setData({
orders: (ordersResponse.data.items || []).map(formatOrder),
storages: (storagesResponse.data.items || []).map(formatStorage),
catalog: catalogResponse ? formatCatalog(catalogResponse.data) : this.data.catalog,
})
} catch (error) {
this.setData({ errorMessage: error.message || '商品服务加载失败' })
} finally {
this.setData({ loading: false })
}
},
async loadBusinessLists() {
try {
const [ordersResponse, storagesResponse] = await Promise.all([
request('/product-orders?page=1&pageSize=50'),
request('/product-storages?page=1&pageSize=50'),
])
this.setData({
orders: (ordersResponse.data.items || []).map(formatOrder),
storages: (storagesResponse.data.items || []).map(formatStorage),
})
} catch (error) {
this.setData({ errorMessage: error.message || '订单与寄存刷新失败' })
}
},
changeTab(event) {
this.setData({ activeTab: event.currentTarget.dataset.tab, selectedStorage: null })
},
changeQuantity(event) {
const skuId = event.currentTarget.dataset.skuId
const delta = Number(event.currentTarget.dataset.delta || 0)
const categories = this.data.catalog.categories.map((category) => ({
...category,
products: category.products.map((product) => ({
...product,
skus: product.skus.map((sku) => sku.id === skuId
? { ...sku, selectedQuantity: Math.max(0, Math.min(99, sku.selectedQuantity + delta)) }
: sku),
})),
}))
this.setData({ 'catalog.categories': categories })
this.rebuildCart(categories)
},
rebuildCart(categories) {
const cartItems = []
categories.forEach((category) => category.products.forEach((product) => {
product.skus.forEach((sku) => {
if (sku.selectedQuantity > 0) cartItems.push({
skuId: sku.id, name: `${product.name} · ${sku.name}`,
quantity: sku.selectedQuantity, salePriceCents: sku.salePriceCents,
})
})
}))
const cartQuantity = cartItems.reduce((sum, item) => sum + item.quantity, 0)
const total = cartItems.reduce((sum, item) => sum + item.quantity * item.salePriceCents, 0)
this.setData({ cartItems, cartQuantity, cartTotalText: cents(total) })
},
async createOrder() {
if (!this.data.storeId || !this.data.cartItems.length || !this.data.catalog.salesOpen) return
await this.run(async () => {
const response = await request('/product-orders', {
method: 'POST',
data: {
storeId: this.data.storeId, requestId: clientRequestId('goods-order'),
fulfillmentMode: 'SELF_SERVICE', roomOrderId: null,
note: '顾客小程序自助商品订单',
items: this.data.cartItems.map((item) => ({
skuId: item.skuId, quantity: item.quantity, note: '',
})),
},
})
wx.showToast({ title: '订单已创建', icon: 'success' })
this.clearCart()
this.setData({ activeTab: 'orders' })
await this.loadBusinessLists()
if (response.data.status === 'PENDING_PAYMENT') wx.showModal({
title: '等待支付',
content: '金额已由服务端复算并锁定库存。生产支付入口启用后可继续支付;超时未支付会自动释放库存。',
showCancel: false,
})
}, '商品订单创建失败')
},
clearCart() {
const categories = this.data.catalog.categories.map((category) => ({
...category,
products: category.products.map((product) => ({
...product,
skus: product.skus.map((sku) => ({ ...sku, selectedQuantity: 0 })),
})),
}))
this.setData({
'catalog.categories': categories,
cartItems: [], cartQuantity: 0, cartTotalText: cents(0),
})
},
async cancelOrder(event) {
const order = this.data.orders.find((item) => item.id === event.currentTarget.dataset.orderId)
if (!order || !(await confirm(`取消商品订单 ${order.orderNo}?库存与退款会按当前状态处理。`))) return
await this.run(async () => {
await request(`/product-orders/${encodeURIComponent(order.id)}/cancel`, {
method: 'POST',
data: { requestId: clientRequestId('goods-cancel'), reason: '顾客小程序取消商品订单' },
})
wx.showToast({ title: '订单已取消', icon: 'success' })
await this.loadBusinessLists()
}, '商品订单取消失败')
},
async storeOrder(event) {
const orderId = event.currentTarget.dataset.orderId
if (!(await confirm('将此已完成商品订单全部转为寄存?领取码只会显示一次。'))) return
await this.run(async () => {
const response = await request('/product-storages', {
method: 'POST',
data: {
requestId: clientRequestId('goods-storage'), sourceOrderId: orderId,
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
},
})
this.showCredential(response.data.claimCredential, '寄存已创建')
this.setData({ activeTab: 'storages' })
await this.loadBusinessLists()
}, '创建寄存失败')
},
async openStorage(event) {
await this.run(async () => {
const response = await request(`/product-storages/${encodeURIComponent(event.currentTarget.dataset.storageId)}`)
this.setData({ selectedStorage: formatStorageDetail(response.data) })
}, '寄存详情加载失败')
},
closeStorage() { this.setData({ selectedStorage: null }) },
async retrieveItem(event) {
const storage = this.data.selectedStorage
const storageItemId = event.currentTarget.dataset.itemId
const item = storage?.items.find((entry) => entry.id === storageItemId)
if (!storage || !item || item.remainingQuantity < 1) return
const credential = await prompt('领取码核验', '请输入当前一次性领取码')
if (!credential) return
const quantity = Number(await prompt(
'取出数量', `当前剩余 ${item.remainingQuantity},请输入本次取出数量`, '1'
))
if (!Number.isInteger(quantity) || quantity < 1 || quantity > item.remainingQuantity) {
this.setData({ errorMessage: '取出数量必须是未超过剩余量的正整数' })
return
}
await this.run(async () => {
const response = await request(`/product-storages/${encodeURIComponent(storage.id)}/retrieve`, {
method: 'POST',
data: {
requestId: clientRequestId('goods-retrieve'), claimCredential: credential,
items: [{ storageItemId, quantity }],
},
})
if (response.data.nextClaimCredential) {
this.showCredential(response.data.nextClaimCredential, '部分取出成功,旧码已失效')
} else wx.showToast({ title: '取出成功', icon: 'success' })
this.setData({ selectedStorage: formatStorageDetail(response.data) })
await this.loadBusinessLists()
}, '商品取出失败')
},
async rotateCredential() {
const storage = this.data.selectedStorage
if (!storage || !ACTIVE_STORAGE.includes(storage.status)
|| !(await confirm('轮换后当前领取码立即失效,新码只显示一次。继续吗?'))) return
await this.run(async () => {
const response = await request(`/product-storages/${encodeURIComponent(storage.id)}/credential/rotate`, {
method: 'POST', data: { requestId: clientRequestId('goods-rotate') },
})
this.showCredential(response.data.claimCredential, '领取码已轮换')
this.setData({ selectedStorage: formatStorageDetail(response.data) })
await this.loadBusinessLists()
}, '领取码轮换失败')
},
async cancelStorage() {
const storage = this.data.selectedStorage
if (!storage || !ACTIVE_STORAGE.includes(storage.status)) return
const reason = await prompt('取消寄存', '取消后不能继续领取,剩余商品不会自动回库存。请输入原因。')
if (!reason) return
await this.run(async () => {
const response = await request(`/product-storages/${encodeURIComponent(storage.id)}/cancel`, {
method: 'POST', data: { requestId: clientRequestId('goods-storage-cancel'), reason },
})
this.setData({ selectedStorage: formatStorageDetail(response.data) })
await this.loadBusinessLists()
wx.showToast({ title: '寄存已取消', icon: 'success' })
}, '取消寄存失败')
},
showCredential(value, title) {
if (value) this.setData({ oneTimeCredential: value, oneTimeCredentialTitle: title })
},
copyCredential() {
if (this.data.oneTimeCredential) wx.setClipboardData({ data: this.data.oneTimeCredential })
},
clearCredential() { this.setData({ oneTimeCredential: '', oneTimeCredentialTitle: '' }) },
async run(work, fallback) {
this.setData({ submitting: true, errorMessage: '' })
try { await work() } catch (error) {
this.setData({ errorMessage: error.message || fallback })
} finally { this.setData({ submitting: false }) }
},
})
function formatCatalog(catalog) {
return {
...catalog,
categories: (catalog.categories || []).map((category) => ({
...category,
products: (category.products || []).map((product) => ({
...product,
skus: (product.skus || []).map((sku) => ({
...sku, priceText: cents(sku.salePriceCents), selectedQuantity: 0,
})),
})),
})),
}
}
function formatOrder(order) {
const labels = {
PENDING_PAYMENT: '待支付', PAID: '已支付', ACCEPTED: '已接单', DELIVERING: '配送中',
READY_FOR_SELF_SERVICE: '待自取', COMPLETED: '已完成', CANCELLED: '已取消',
REFUNDING: '退款中', REFUNDED: '已退款', REFUND_FAILED: '退款失败',
}
return {
...order, statusText: labels[order.status] || order.status,
amountText: cents(order.totalAmountCents), createdText: formatDate(order.createdAt),
canCancel: ['PENDING_PAYMENT', 'PAID', 'ACCEPTED'].includes(order.status),
canStore: order.status === 'COMPLETED',
}
}
function formatStorage(storage) {
const labels = {
STORED: '已寄存', PARTIALLY_RETRIEVED: '部分取出', RETRIEVED: '已取完',
EXPIRED: '已过期', CANCELLED: '已取消',
}
return {
...storage, statusText: labels[storage.status] || storage.status,
expiresText: formatDate(storage.expiresAt), active: ACTIVE_STORAGE.includes(storage.status),
}
}
function formatStorageDetail(storage) {
return {
...formatStorage(storage), items: (storage.items || []).map((item) => ({ ...item })),
movements: (storage.movements || []).map((item) => ({
...item, createdText: formatDate(item.createdAt),
})),
}
}
function formatDate(value) {
if (!value) return '-'
const date = new Date(value)
const pad = (input) => String(input).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function confirm(content) {
return new Promise((resolve) => wx.showModal({
title: '请确认', content,
success: (result) => resolve(Boolean(result.confirm)), fail: () => resolve(false),
}))
}
function prompt(title, placeholderText, defaultValue = '') {
return new Promise((resolve) => wx.showModal({
title, placeholderText, editable: true, content: defaultValue,
success: (result) => resolve(result.confirm ? String(result.content || '').trim() : ''),
fail: () => resolve(''),
}))
}
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "商品与寄存"
}
+118
View File
@@ -0,0 +1,118 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="page">
<view class="hero">
<view>
<view class="eyebrow">M09 · 门店增值服务</view>
<view class="title">商品与寄存</view>
</view>
<view class="hero-store">门店 #{{storeId || '-'}}</view>
</view>
<view class="tabs">
<view class="tab {{activeTab === 'catalog' ? 'active' : ''}}" data-tab="catalog" bindtap="changeTab">选购</view>
<view class="tab {{activeTab === 'orders' ? 'active' : ''}}" data-tab="orders" bindtap="changeTab">商品订单</view>
<view class="tab {{activeTab === 'storages' ? 'active' : ''}}" data-tab="storages" bindtap="changeTab">我的寄存</view>
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view wx:if="{{loading}}" class="notice">正在加载商品服务…</view>
<view wx:if="{{oneTimeCredential}}" class="credential-card">
<view class="credential-title">{{oneTimeCredentialTitle}}</view>
<view class="credential-warning">领取码仅展示本次,不会写入本地缓存。请立即妥善保存。</view>
<view class="credential-value">{{oneTimeCredential}}</view>
<view class="row-actions">
<button class="button primary compact" bindtap="copyCredential">复制领取码</button>
<button class="button ghost compact" bindtap="clearCredential">我已保存并隐藏</button>
</view>
</view>
<block wx:if="{{activeTab === 'catalog'}}">
<view wx:if="{{!storeId}}" class="notice">请从门店详情进入商品页,以载入对应门店的商品目录。</view>
<view wx:elif="{{!catalog.salesOpen}}" class="notice warning">当前不在商品售卖时段,可查看目录,暂不能提交订单。</view>
<block wx:for="{{catalog.categories}}" wx:key="id" wx:for-item="category">
<view class="section-title">{{category.name}}</view>
<view wx:for="{{category.products}}" wx:key="id" wx:for-item="product" class="product-card">
<image wx:if="{{product.coverUrl}}" class="product-cover" src="{{product.coverUrl}}" mode="aspectFill" />
<view class="product-main">
<view class="product-head">
<view>
<view class="product-name">{{product.name}}</view>
<view class="meta">{{product.unitName}} · {{product.fulfillmentMode === 'DELIVERY' ? '可配送' : '门店自取'}}</view>
</view>
<view wx:if="{{product.storageEnabled}}" class="chip">支持寄存</view>
</view>
<view wx:if="{{product.description}}" class="description">{{product.description}}</view>
<view wx:for="{{product.skus}}" wx:key="id" wx:for-item="sku" class="sku-row">
<view>
<view class="sku-name">{{sku.name}}</view>
<view class="price">{{sku.priceText}}</view>
</view>
<view class="quantity-control">
<button class="quantity-button" data-sku-id="{{sku.id}}" data-delta="-1" bindtap="changeQuantity"></button>
<text>{{sku.selectedQuantity}}</text>
<button class="quantity-button plus" data-sku-id="{{sku.id}}" data-delta="1" bindtap="changeQuantity"></button>
</view>
</view>
</view>
</view>
</block>
<view wx:if="{{storeId && !loading && !catalog.categories.length}}" class="empty">当前门店暂无在售商品</view>
<view wx:if="{{cartQuantity > 0}}" class="cart-bar">
<view><view class="cart-count">已选 {{cartQuantity}} 件</view><view class="cart-total">{{cartTotalText}}</view></view>
<button class="button primary cart-submit" loading="{{submitting}}" disabled="{{submitting || !catalog.salesOpen}}" bindtap="createOrder">提交订单</button>
</view>
</block>
<block wx:if="{{activeTab === 'orders'}}">
<view wx:for="{{orders}}" wx:key="id" wx:for-item="order" class="business-card">
<view class="card-head">
<view><view class="card-title">{{order.orderNo}}</view><view class="meta">{{order.createdText}}</view></view>
<view class="status">{{order.statusText}}</view>
</view>
<view class="summary-row"><text>{{order.totalQuantity}} 件 · {{order.fulfillmentMode === 'DELIVERY' ? '配送' : '门店自取'}}</text><strong>{{order.amountText}}</strong></view>
<view class="row-actions">
<button wx:if="{{order.canCancel}}" class="button ghost compact" data-order-id="{{order.id}}" bindtap="cancelOrder">取消订单</button>
<button wx:if="{{order.canStore}}" class="button primary compact" data-order-id="{{order.id}}" bindtap="storeOrder">整单寄存</button>
</view>
</view>
<view wx:if="{{!loading && !orders.length}}" class="empty">暂无商品订单</view>
</block>
<block wx:if="{{activeTab === 'storages'}}">
<view wx:if="{{!selectedStorage}}">
<view wx:for="{{storages}}" wx:key="id" wx:for-item="storage" class="business-card tappable" data-storage-id="{{storage.id}}" bindtap="openStorage">
<view class="card-head">
<view><view class="card-title">{{storage.storageNo}}</view><view class="meta">到期 {{storage.expiresText}}</view></view>
<view class="status">{{storage.statusText}}</view>
</view>
<view class="summary-row"><text>剩余 / 总数</text><strong>{{storage.remainingQuantity}} / {{storage.totalQuantity}}</strong></view>
<view class="detail-link">查看寄存明细与取出记录 </view>
</view>
<view wx:if="{{!loading && !storages.length}}" class="empty">暂无商品寄存</view>
</view>
<view wx:if="{{selectedStorage}}" class="storage-detail">
<view class="back" bindtap="closeStorage"> 返回寄存列表</view>
<view class="card-head">
<view><view class="card-title">{{selectedStorage.storageNo}}</view><view class="meta">到期 {{selectedStorage.expiresText}}</view></view>
<view class="status">{{selectedStorage.statusText}}</view>
</view>
<view class="credential-warning">取出时需输入当前领取码;服务端仅保存不可逆摘要。</view>
<view wx:for="{{selectedStorage.items}}" wx:key="id" wx:for-item="item" class="storage-item">
<view><view class="sku-name">{{item.productName}} · {{item.skuName}}</view><view class="meta">剩余 {{item.remainingQuantity}} / {{item.totalQuantity}}</view></view>
<button wx:if="{{selectedStorage.active && item.remainingQuantity > 0}}" class="button primary compact" data-item-id="{{item.id}}" bindtap="retrieveItem">取出</button>
</view>
<view wx:if="{{selectedStorage.active}}" class="row-actions detail-actions">
<button class="button ghost compact" bindtap="rotateCredential">更换领取码</button>
<button class="button danger compact" bindtap="cancelStorage">取消寄存</button>
</view>
<view class="section-title small">取出流水</view>
<view wx:for="{{selectedStorage.movements}}" wx:key="id" wx:for-item="movement" class="movement-row">
<text>{{movement.createdText}} · 明细 #{{movement.storageItemId}}</text><strong>取出 {{movement.quantity}},剩余 {{movement.remainingAfter}}</strong>
</view>
<view wx:if="{{!selectedStorage.movements.length}}" class="empty inline">暂无取出记录</view>
</view>
</block>
</view>
</scroll-view>
+52
View File
@@ -0,0 +1,52 @@
.scrollarea { height: 100vh; background: #f4f6f8; color: #152019; }
.page { min-height: 100%; padding: 36rpx 28rpx 180rpx; box-sizing: border-box; }
.hero { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 28rpx; }
.eyebrow { color: #6a776f; font-size: 22rpx; letter-spacing: 2rpx; }
.title { margin-top: 8rpx; font-size: 46rpx; line-height: 1.2; font-weight: 750; }
.hero-store { padding: 10rpx 16rpx; border-radius: 999rpx; background: #e4ebe7; color: #506158; font-size: 22rpx; }
.tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8rpx; margin-bottom: 24rpx; padding: 8rpx; border-radius: 22rpx; background: #e7ece9; }
.tab { padding: 18rpx 8rpx; border-radius: 16rpx; color: #647168; font-size: 26rpx; text-align: center; }
.tab.active { background: #fff; color: #16794b; font-weight: 700; box-shadow: 0 6rpx 20rpx rgba(22, 60, 40, .08); }
.notice, .error, .empty { margin: 20rpx 0; padding: 28rpx; border-radius: 20rpx; background: #fff; color: #6a756e; text-align: center; }
.notice.warning { background: #fff6dd; color: #865b00; }
.error { background: #fff0ee; color: #b53b31; text-align: left; }
.section-title { margin: 34rpx 4rpx 18rpx; font-size: 30rpx; font-weight: 750; }
.section-title.small { margin-top: 36rpx; font-size: 26rpx; }
.product-card, .business-card, .storage-detail, .credential-card { margin-bottom: 20rpx; overflow: hidden; border: 1rpx solid #e4e9e6; border-radius: 24rpx; background: #fff; box-shadow: 0 10rpx 30rpx rgba(38, 57, 46, .05); }
.product-cover { display: block; width: 100%; height: 280rpx; background: #e6ebe8; }
.product-main, .business-card, .storage-detail, .credential-card { padding: 26rpx; }
.product-head, .card-head, .summary-row, .storage-item { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.product-name, .card-title { font-size: 30rpx; font-weight: 750; }
.meta, .description { margin-top: 8rpx; color: #748077; font-size: 23rpx; line-height: 1.5; }
.description { margin: 20rpx 0 4rpx; }
.chip, .status { flex: 0 0 auto; padding: 8rpx 14rpx; border-radius: 999rpx; background: #e4f3eb; color: #17794c; font-size: 21rpx; }
.sku-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; padding-top: 22rpx; border-top: 1rpx solid #edf0ee; }
.sku-name { font-size: 26rpx; font-weight: 650; }
.price { margin-top: 6rpx; color: #d45d32; font-size: 28rpx; font-weight: 750; }
.quantity-control { display: flex; align-items: center; gap: 18rpx; }
.quantity-button { width: 58rpx; min-width: 58rpx; height: 58rpx; padding: 0; border: 0; border-radius: 18rpx; background: #eef2ef; color: #486052; font-size: 34rpx; line-height: 58rpx; }
.quantity-button::after, .button::after { border: 0; }
.quantity-button.plus { background: #16794b; color: #fff; }
.summary-row { margin-top: 26rpx; padding-top: 22rpx; border-top: 1rpx solid #edf0ee; color: #657269; font-size: 25rpx; }
.summary-row strong { color: #18251d; font-size: 28rpx; }
.row-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 14rpx; margin-top: 22rpx; }
.button { margin: 0; border-radius: 16rpx; font-size: 25rpx; font-weight: 650; }
.button.compact { min-width: 150rpx; padding: 0 24rpx; line-height: 70rpx; }
.button.primary { background: #16794b; color: #fff; }
.button.ghost { background: #edf2ef; color: #315442; }
.button.danger { background: #fff0ee; color: #b23f37; }
.detail-link, .back { margin-top: 22rpx; color: #16794b; font-size: 24rpx; }
.tappable:active { transform: scale(.992); }
.credential-card { border-color: #f2d783; background: #fffbeb; }
.credential-title { color: #765100; font-size: 28rpx; font-weight: 750; }
.credential-warning { margin-top: 10rpx; color: #8b6b1d; font-size: 23rpx; line-height: 1.5; }
.credential-value { margin-top: 20rpx; padding: 20rpx; border-radius: 16rpx; background: #fff; font-family: monospace; font-size: 25rpx; line-height: 1.5; word-break: break-all; user-select: all; }
.storage-item { margin-top: 22rpx; padding-top: 22rpx; border-top: 1rpx solid #edf0ee; }
.detail-actions { justify-content: flex-start; }
.movement-row { display: grid; gap: 8rpx; padding: 18rpx 0; border-top: 1rpx solid #edf0ee; color: #6b786f; font-size: 22rpx; }
.movement-row strong { color: #27362d; font-size: 24rpx; }
.empty.inline { margin: 0; padding: 20rpx; background: #f7f9f8; }
.cart-bar { position: fixed; right: 24rpx; bottom: calc(24rpx + env(safe-area-inset-bottom)); left: 24rpx; z-index: 20; display: flex; align-items: center; justify-content: space-between; padding: 22rpx 24rpx; border-radius: 24rpx; background: #13291e; color: #fff; box-shadow: 0 18rpx 50rpx rgba(14, 42, 27, .25); }
.cart-count { color: #b8c8bf; font-size: 22rpx; }
.cart-total { margin-top: 4rpx; font-size: 32rpx; font-weight: 750; }
.cart-submit { min-width: 210rpx; }
+4
View File
@@ -40,6 +40,10 @@ Page({
openBenefits() { openBenefits() {
wx.navigateTo({ url: '/pages/benefits/index' }) wx.navigateTo({ url: '/pages/benefits/index' })
}, },
openGoods() {
wx.navigateTo({ url: '/pages/goods/index?tab=orders' })
},
}) })
function formatProfile(profile) { function formatProfile(profile) {
+1
View File
@@ -73,6 +73,7 @@
</view> </view>
<button bindtap="openRecharge">充值</button> <button bindtap="openRecharge">充值</button>
<button bindtap="openOrders">查看订单</button> <button bindtap="openOrders">查看订单</button>
<button bindtap="openGoods">商品订单与寄存</button>
</view> </view>
<view class="section-title ledger-title">最近账单</view> <view class="section-title ledger-title">最近账单</view>
+7
View File
@@ -44,6 +44,13 @@ Page({
}) })
}, },
openGoods() {
if (!this.data.storeId) return
wx.navigateTo({
url: `/pages/goods/index?storeId=${encodeURIComponent(this.data.storeId)}`,
})
},
async loadWifi() { async loadWifi() {
if (!this.data.storeId) return if (!this.data.storeId) return
this.setData({ loading: true, errorMessage: '' }) this.setData({ loading: true, errorMessage: '' })
+1
View File
@@ -8,6 +8,7 @@
<view wx:else class="title">门店详情</view> <view wx:else class="title">门店详情</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view> <view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<button class="goods-entry" bindtap="openGoods">选购商品 · 查看商品订单与寄存</button>
<view class="section-title">房间</view> <view class="section-title">房间</view>
<view wx:if="{{!loading && rooms.length === 0}}" class="empty">暂无可预订房间</view> <view wx:if="{{!loading && rooms.length === 0}}" class="empty">暂无可预订房间</view>
+6
View File
@@ -67,3 +67,9 @@
color: #888888; color: #888888;
text-align: center; text-align: center;
} }
.goods-entry {
margin: 24rpx 0;
color: #ffffff;
background: #1d4ed8;
}
+3 -3
View File
@@ -24,11 +24,11 @@ if (!distOnly) {
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/); assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts'); const router = read('admin/src/router.ts');
for (const path of ['/overview', '/stores', '/orders', '/payments', '/third-party', for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party',
'/people', '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) { '/people', '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) {
assert.match(read('admin/src/navigation.mjs'), new RegExp(`path: '${path.replace('/', '\\/')}'`)); assert.match(read('admin/src/navigation.mjs'), new RegExp(`path: '${path.replace('/', '\\/')}'`));
} }
assert.equal((router.match(/\(\) => import\('/g) || []).length, 12); assert.equal((router.match(/\(\) => import\('/g) || []).length, 13);
assert.match(router, /router\.beforeEach/); assert.match(router, /router\.beforeEach/);
assert.match(router, /isNavigationAllowed/); assert.match(router, /isNavigationAllowed/);
@@ -60,7 +60,7 @@ if (!sourceOnly) {
.sort((left, right) => right.bytes - left.bytes)[0]; .sort((left, right) => right.bytes - left.bytes)[0];
assert.ok(largest.bytes < 500 * 1024, `chunk ${largest.name} exceeds 500 KiB`); assert.ok(largest.bytes < 500 * 1024, `chunk ${largest.name} exceeds 500 KiB`);
for (const chunk of ['OperationsOverviewPanel', 'StoresRoomsPanel', 'OrdersPanel', for (const chunk of ['OperationsOverviewPanel', 'StoresRoomsPanel', 'OrdersPanel',
'PaymentsPanel', 'ThirdPartyPanel', 'MembersStaffPanel', 'CleaningWorkspace', 'ProductOperationsPanel', 'PaymentsPanel', 'ThirdPartyPanel', 'MembersStaffPanel', 'CleaningWorkspace',
'DevicesPanel', 'PlatformAppsPanel', 'ContentManagementPanel', 'FranchisePanel', 'DevicesPanel', 'PlatformAppsPanel', 'ContentManagementPanel', 'FranchisePanel',
'LogsSystemPanel']) assert.ok(javascript.some((name) => name.startsWith(`${chunk}-`)), `${chunk} is not lazy`); 'LogsSystemPanel']) assert.ok(javascript.some((name) => name.startsWith(`${chunk}-`)), `${chunk} is not lazy`);
const entry = javascript.find((name) => name.startsWith('index-')); const entry = javascript.find((name) => name.startsWith('index-'));
+2 -2
View File
@@ -24,10 +24,10 @@ for (const pattern of [
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/); assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts'); const router = read('admin/src/router.ts');
assert.equal((router.match(/\(\) => import\('/g) || []).length, 12); assert.equal((router.match(/\(\) => import\('/g) || []).length, 13);
assert.match(router, /router\.beforeEach/); assert.match(router, /router\.beforeEach/);
const navigation = read('admin/src/navigation.mjs'); const navigation = read('admin/src/navigation.mjs');
for (const path of ['/overview', '/stores', '/orders', '/payments', '/third-party', '/people', for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`)); '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`));
const cleaningWorkspace = read('admin/src/components/CleaningWorkspace.vue'); const cleaningWorkspace = read('admin/src/components/CleaningWorkspace.vue');
@@ -0,0 +1,82 @@
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');
const routes = read('backend/src/routes/products.ts');
for (const pattern of [
'/app-api/stores/:storeId/product-catalog',
'authenticateAccessToken',
'listStoreCatalog',
'tenantId: auth.session.tenantId'
]) assert.ok(routes.includes(pattern), `customer catalog route is missing ${pattern}`);
const routeTest = read('backend/tests/product-route.test.mjs');
assert.match(routeTest, /app-api\/stores\/11\/product-catalog/);
assert.match(routeTest, /customerCatalog\.statusCode, 200/);
assert.match(routeTest, /statusCode, 401/);
const navigation = read('admin/src/navigation.mjs');
const router = read('admin/src/router.ts');
const app = read('admin/src/App.vue');
assert.match(navigation, /menuKey: 'products'/);
assert.match(navigation, /path: '\/products'/);
for (const capability of [
'product.catalog.read', 'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage'
]) assert.ok(navigation.includes(capability), `product navigation is missing ${capability}`);
assert.match(router, /ProductOperationsPanel\.vue/);
assert.match(app, /ShoppingBasket/);
const api = read('admin/src/api.ts');
for (const pattern of [
'listProductListings', 'listInventoryStocks', 'mutateInventory',
'listProductOrders', 'getProductOrder', 'runProductOrderAction',
'listProductStorages', 'getProductStorage', 'createOrderProductStorage',
'runProductStorageAction'
]) assert.ok(api.includes(pattern), `admin product API is missing ${pattern}`);
const panel = read('admin/src/components/ProductOperationsPanel.vue');
for (const pattern of [
'canCatalogRead', 'canInventoryRead', 'canInventoryWrite',
'canOrderRead', 'canOrderWrite', 'canStorageRead', 'canStorageWrite',
'inventoryQuantity', 'stocktake', 'orderActions', 'submitRetrieve',
'rotateStorage', 'submitCreateStorage', '一次性领取码'
]) assert.ok(panel.includes(pattern), `product operations panel is missing ${pattern}`);
assert.match(panel, /Promise\.all/);
assert.match(panel, /:disabled="!canCatalogRead"/);
const appJson = JSON.parse(read('miniapp/app.json'));
assert.ok(appJson.pages.includes('pages/goods/index'));
for (const path of [
'miniapp/pages/goods/index.js', 'miniapp/pages/goods/index.json',
'miniapp/pages/goods/index.wxml', 'miniapp/pages/goods/index.wxss'
]) assert.ok(existsSync(join(root, path)), `${path} is missing`);
const goodsScript = read('miniapp/pages/goods/index.js');
for (const pattern of [
'/product-orders', '/product-storages', '/product-catalog',
'createOrder', 'cancelOrder', 'storeOrder', 'retrieveItem',
'rotateCredential', 'cancelStorage', 'showCredential', 'clearCredential'
]) assert.ok(goodsScript.includes(pattern), `miniapp goods flow is missing ${pattern}`);
assert.doesNotMatch(goodsScript, /(?:wx\.)?setStorage(?:Sync)?\s*\(/,
'one-time storage credential must not be persisted locally');
const goodsView = read('miniapp/pages/goods/index.wxml');
for (const pattern of [
'选购', '商品订单', '我的寄存', '领取码仅展示本次',
'提交订单', '整单寄存', '更换领取码', '取出流水'
]) assert.ok(goodsView.includes(pattern), `miniapp goods view is missing ${pattern}`);
const entryPoints = read('miniapp/pages/store/detail.js')
+ read('miniapp/pages/store/detail.wxml')
+ read('miniapp/pages/profile/index.js')
+ read('miniapp/pages/profile/index.wxml');
assert.match(entryPoints, /pages\/goods\/index/);
assert.match(entryPoints, /选购商品/);
console.log(
'PASS: M09-D4 authenticated catalog, capability-scoped admin workspace, customer order/storage flow and one-time credential gates are present.'
);
+2
View File
@@ -44,6 +44,8 @@ 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 node scripts/check-m09-d3-product-storage.mjs
Assert-NativeSuccess "check-m09-d3-product-storage" Assert-NativeSuccess "check-m09-d3-product-storage"
node scripts/check-m09-d4-product-experience.mjs
Assert-NativeSuccess "check-m09-d4-product-experience"
if (Test-Path "admin/package.json") { if (Test-Path "admin/package.json") {
Push-Location admin Push-Location admin