diff --git a/README.md b/README.md index 9981abb..3a0a01e 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,9 @@ sh scripts/setup-git-hooks.sh 项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。 -- 当前执行游标:`M09-D4`(TODO);`M09-D3` 已在当前工作树完成商品寄存与安全取出后端闭环,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。 -- 最近工程结果:基于已推送工程提交 `1a3ea7b`,M09-D3 已建立寄存单、寄存明细、不可变状态事件和逐项数量流水;支持已完成商品订单或受控手工寄存、本人/授权员工分次取出、一次性哈希凭证轮换、取消/过期和通知 outbox。MySQL 8.4.10 的完整往返、跨租户、超量及并发防超领回归通过。 -- 下一工程目标:执行 M09-D4,在顾客小程序和运营后台接入商品目录、订单、库存及寄存/取出工作台,并完成响应式和权限门禁。 +- 当前执行游标:`M09-REGRESSION`(TODO);M09-D4 顾客小程序与 Vue 商品运营工作台已完成交付级回归,随本次中文工程提交固化;真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。 +- 最近工程结果:已推送工程提交 `60264cb` 完成 M09-D3 商品寄存与安全取出闭环;当前 M09-D4 随本次中文工程提交固化登录态顾客目录、选购/订单/寄存小程序页,以及按目录、库存、订单、寄存能力分域的响应式运营工作台。领取码只在签发当次显示,不写入小程序本地缓存。 +- 下一工程目标:执行 `M09-REGRESSION`,完成商品目录、库存、订单支付/退款、寄存数量、权限与界面的全链路对账和模块级验收。 ## 版本递进 @@ -147,7 +147,7 @@ sh scripts/setup-git-hooks.sh - **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。 - **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。 - **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。 -- **V5.7**:增加中文 Git 提交门禁,持续收口 M09-D1 至 D3 商品库存、订单与寄存取出生命周期,游标推进至 M09-D4。 +- **V5.7**:增加中文 Git 提交门禁,持续收口 M09-D1 至 D4 商品库存、订单、寄存取出与端到端运营体验,游标推进至 M09 模块回归前夕。 ## Codex 入口 diff --git a/V5.7.md b/V5.7.md index 6b8ffaf..99dd320 100644 --- a/V5.7.md +++ b/V5.7.md @@ -1,11 +1,11 @@ # 自助棋牌室系统开发总纲(V5.7) -> 文档版本:V5.7(中文 Git 提交门禁 + `1a3ea7b` 最新已推送源码校准 + M09-D3 在制收口 + 持续工程开发版) +> 文档版本:V5.7(中文 Git 提交门禁 + `60264cb` 最新已推送源码校准 + M09-D4 在制收口 + 持续工程开发版) > 更新日期:2026-08-11(Asia/Shanghai) > 直接前序版本:V5.6;本文完整继承 V5.6 的全部功能、架构、协议、部署、安全、测试、验收和持续开发要求。 -> 最新已审计提交证据:`1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620`,且接管时 `HEAD == origin/main`;其后的 M09-D3 工作区代码待随本次中文工程提交固化。 -> 默认执行起点:保护并继续完成当前 M09-D3 在制工作;通过编译、全量测试、真实 MySQL 往返和静态门禁并以中文提交推送后,进入 `M09-D4 → M09-REGRESSION → M10-A/B/C/D → M10-RC1`。 +> 最新已审计提交证据:`60264cbd11e3cbb759d40405ba364238a8a2c559`,且接管时 `HEAD == origin/main`;其后的 M09-D4 工作区代码随本次中文工程提交固化。 +> 默认执行起点:M09-D4 已通过编译、Windows 全量、真实 MySQL 往返与静态门禁并随本次中文工程提交固化;推送校验后进入 `M09-REGRESSION → M10-A/B/C/D → M10-RC1`。 > 唯一交接目标:Codex 只阅读根目录 `V5.7.md`,即可保护当前工作区,直接继续工程编码、测试、中文提交、推送和远端校验,自动推进至 `CODE_COMPLETE`,外部条件具备后继续至 `PRODUCTION_ACCEPTED`。 > **V5.7 最高优先级控制规则:** 本文开头“V5.7 增量控制层”和全文最后“第 30 章 V5.7 最终固定游标”共同构成唯一当前执行入口,优先于中间完整继承的 V5.6/V5.5/V5.4 历史标题、旧 HEAD、旧队列、旧示例和旧入口。中间历史正文继续作为完整需求与验收依据,但不得据此把执行游标回退到已经有工程证据的阶段。 @@ -15,18 +15,19 @@ # 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`。 - M09-D2 已由 `1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620` 以 `feat(M09-D2): 完成商品订单与库存占用生命周期` 中文业务提交闭环并推送,接管时 `HEAD == origin/main`。 -- M09-D3 当前工作树已完成寄存记录、寄存明细、不可变状态事件和逐项数量流水 4 张表;来源限定为已完成商品订单或受控员工手工创建。 +- M09-D3 已完成寄存记录、寄存明细、不可变状态事件和逐项数量流水 4 张表;来源限定为已完成商品订单或受控员工手工创建。 - 顾客本人或授权员工可分次领取;领取凭证仅保存 SHA-256,部分领取后自动轮换,终态消费,创建和动作重放均不重新泄露已签发原文。行锁、版本 CAS、幂等事件和有限事务重试保证并发不超领。 - 顾客与管理 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;跨租户/跨寄存人拒绝、超量拒绝、部分取出、旧凭证失效、并发全量取出单赢家、取消、过期和不可变流水均有真实数据库证据。 -- M09-D3 工程与本文档伴随记录待以 `feat(M09-D3): 完成商品寄存与安全取出闭环` 中文业务提交取得 SHA;文档不得虚构尚未产生的提交号。 -- 唯一执行游标已推进到 `M09-D4`,下一目标为顾客小程序与运营后台商品、订单、库存、寄存取出界面。M09-D1/D2/D3 若出现回归只做针对性修复,不得删除重做。 +- M09-D3 已由 `60264cbd11e3cbb759d40405ba364238a8a2c559` 以 `feat(M09-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 本次升级的唯一事实边界 @@ -7071,14 +7072,14 @@ TARGET=CODE_COMPLETE_THEN_RELEASE_CANDIDATE_THEN_PRODUCTION_ACCEPTED execution_cursor_v57: document_version: V5.7 audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f - evidence_head: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620 - evidence_origin_main: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620 + evidence_head: 60264cbd11e3cbb759d40405ba364238a8a2c559 + evidence_origin_main: 60264cbd11e3cbb759d40405ba364238a8a2c559 base_branch: main remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git - current_stage: M09-D4 - current_stage_status: TODO_AFTER_M09_D3_WORKTREE_CLOSURE - last_completed_functional_stage: M09-D3 - last_engineering_commit: 1a3ea7bdf0bc55f85a87ef3c251fc7bbeb7d1620 + current_stage: M09-REGRESSION + current_stage_status: TODO_AFTER_M09_D4_CURRENT_COMMIT + last_completed_functional_stage: M09-D4 + last_engineering_commit: 60264cbd11e3cbb759d40405ba364238a8a2c559 last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1 m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a 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_concurrent_payment_deduct: 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_backend_build_and_full_test: PASS m09_d3_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN m09_d3_one_time_credential: PASS m09_d3_concurrent_retrieval: PASS - default_next_stage: M09-REGRESSION + 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 worktree_protected: true unresolved_deletions_policy: EXCLUDE_FROM_STAGE_COMMIT_UNTIL_USER_CONFIRMS @@ -7113,13 +7120,12 @@ execution_cursor_v57: 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 最终唯一队列 ```text -M09-D4 -→ M09-REGRESSION +M09-REGRESSION → M10-A → M10-B → M10-C @@ -7130,13 +7136,13 @@ M09-D4 → 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 -M09_D3_WORKTREE_PROTECTED=PASS -M09_D3_STATIC_GATE=PASS +M09_D4_WORKTREE_PROTECTED=PASS +M09_D4_STATIC_GATE=PASS BACKEND_BUILD=PASS BACKEND_FULL_TEST=PASS MYSQL_84_ROUNDTRIP=PASS @@ -7147,7 +7153,7 @@ PUSH_VERIFIED=PASS REMOTE_COMMIT_SUBJECT_CN=PASS ``` -任一项未通过时保持 `CURRENT_STAGE=M09-D3`,修复后重跑;不能先更新为 DONE 或进入 M09-D4。 +任一项未通过时保持 `CURRENT_STAGE=M09-D4`,修复后重跑;不能先更新为 DONE 或进入 M09-REGRESSION。 ## 30.3 每次提交和推送的最终语言门禁 @@ -7208,4 +7214,4 @@ USER_FACING_LANGUAGE=ZH_CN 请阅读 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,禁止删除已完成工程重做,禁止把未决删除混入业务提交,禁止把英文摘要推送到远端,禁止把外部阻塞扩大为内部停工理由。 diff --git a/admin/src/App.vue b/admin/src/App.vue index 7cf89fd..3f40cbf 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -111,6 +111,7 @@ import { ScrollText, Sparkles, Store, + ShoppingBasket, TicketCheck, UsersRound, WalletCards @@ -158,6 +159,7 @@ const navigationIcons: Record = { ScrollText, Sparkles, Store, + ShoppingBasket, TicketCheck, UsersRound, WalletCards diff --git a/admin/src/api.ts b/admin/src/api.ts index 7d0a451..6ff3bd2 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -36,6 +36,10 @@ import type { OrderHistoryItem, OrderStatus, PaymentAuthorizationStatus, + ProductListing, + ProductOrder, + ProductStorage, + InventoryStock, PlatformApplication, ProfitSharingSnapshot, PayoutStateFilter, @@ -344,6 +348,85 @@ export function getBusinessStatistics( return request(session, `/statistics?${params}`); } +export function listProductListings(session: ApiSession, storeId: string) { + return request(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>(session, `/inventory/stocks?${params}`); +} + +export function mutateInventory( + session: ApiSession, skuId: string, + action: 'inbound' | 'adjust' | 'stocktake' | 'loss' | 'policy', + input: Record +) { + return request>(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>(session, `/product-orders?${params}`); +} + +export function getProductOrder(session: ApiSession, orderId: string) { + return request(session, `/product-orders/${encodeURIComponent(orderId)}`); +} + +export function runProductOrderAction( + session: ApiSession, orderId: string, + input: { requestId: string; action: string; reason: string } +) { + return request(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>(session, `/product-storages?${params}`); +} + +export function getProductStorage(session: ApiSession, storageId: string) { + return request(session, `/product-storages/${encodeURIComponent(storageId)}`); +} + +export function createOrderProductStorage( + session: ApiSession, input: { requestId: string; sourceType: 'ORDER'; sourceOrderId: string; expiresAt: string } +) { + return request(session, '/product-storages', { + method: 'POST', body: JSON.stringify(input) + }); +} + +export function runProductStorageAction( + session: ApiSession, storageId: string, action: 'retrieve' | 'credential/rotate' | 'cancel', + input: Record +) { + return request(session, + `/product-storages/${encodeURIComponent(storageId)}/${action}`, + { method: 'POST', body: JSON.stringify(input) }); +} + async function request( session: ApiSession, path: string, diff --git a/admin/src/components/ProductOperationsPanel.vue b/admin/src/components/ProductOperationsPanel.vue new file mode 100644 index 0000000..5ae27c8 --- /dev/null +++ b/admin/src/components/ProductOperationsPanel.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/admin/src/navigation.d.mts b/admin/src/navigation.d.mts index bbb92e1..0822699 100644 --- a/admin/src/navigation.d.mts +++ b/admin/src/navigation.d.mts @@ -2,6 +2,7 @@ export type AdminMenuKey = | 'overview' | 'stores' | 'orders' + | 'products' | 'payments' | 'thirdParty' | 'people' diff --git a/admin/src/navigation.mjs b/admin/src/navigation.mjs index a40054e..2341386 100644 --- a/admin/src/navigation.mjs +++ b/admin/src/navigation.mjs @@ -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: '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: '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: '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'] }, diff --git a/admin/src/router.ts b/admin/src/router.ts index 7a65532..f47db38 100644 --- a/admin/src/router.ts +++ b/admin/src/router.ts @@ -11,6 +11,7 @@ const routeComponents = { overview: () => import('./components/OperationsOverviewPanel.vue'), stores: () => import('./components/StoresRoomsPanel.vue'), orders: () => import('./components/OrdersPanel.vue'), + products: () => import('./components/ProductOperationsPanel.vue'), payments: () => import('./components/PaymentsPanel.vue'), thirdParty: () => import('./components/ThirdPartyPanel.vue'), people: () => import('./components/MembersStaffPanel.vue'), diff --git a/admin/src/types.ts b/admin/src/types.ts index 5e1f85d..4d58993 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -22,6 +22,51 @@ export interface PageResult { 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>; + movements?: Array>; claimCredential?: string | null; + nextClaimCredential?: string | null; +} + +export interface ProductStorageItem { + id: string; productName: string; skuName: string; skuId: string; + totalQuantity: number; remainingQuantity: number; +} + export interface ManagedStore { id: string; name: string; diff --git a/admin/tests/navigation.test.mjs b/admin/tests/navigation.test.mjs index 00ae68b..57be0e3 100644 --- a/admin/tests/navigation.test.mjs +++ b/admin/tests/navigation.test.mjs @@ -9,11 +9,12 @@ import { assert.deepEqual( 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'] ); assert.equal(pathForMenu('platformApps'), '/apps'); assert.equal(pathForMenu('thirdParty'), '/third-party'); +assert.equal(pathForMenu('products'), '/products'); const storeOperator = { roles: ['STORE_ADMIN'], @@ -24,6 +25,14 @@ assert.equal(resolveAuthorizedPath(storeOperator, 'orders'), '/orders'); assert.equal(resolveAuthorizedPath(storeOperator, 'system'), '/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 = { roles: ['STORE_ADMIN'], capabilities: ['store.operation.read'], diff --git a/backend/src/routes/admin-auth.ts b/backend/src/routes/admin-auth.ts index 69ee28d..4ca3fad 100644 --- a/backend/src/routes/admin-auth.ts +++ b/backend/src/routes/admin-auth.ts @@ -113,8 +113,10 @@ function adminAccess(access: AccessProfile) { const storeRead = tenant || access.capabilities.includes('store.operation.read'); const menus = [ ...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []), - ...(tenant || access.capabilities.includes('product.catalog.read') - || access.capabilities.includes('inventory.read') ? ['products'] : []), + ...(tenant || access.capabilities.some((capability) => [ + '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 || access.capabilities.includes('device.read') ? ['devices'] : []), ...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : []) diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts index f116fb2..50658da 100644 --- a/backend/src/routes/products.ts +++ b/backend/src/routes/products.ts @@ -119,13 +119,34 @@ export interface ProductRouteOptions { | 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct' | 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku' | 'listListings' | 'putListing' | 'archiveListing' - | 'getStoreSettings' | 'putStoreSettings'>; + | 'getStoreSettings' | 'putStoreSettings' | 'listStoreCatalog'>; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; } 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 [ '/admin-api/stores/:storeId/product-categories', '/app-api/management/stores/:storeId/product-categories' diff --git a/backend/tests/product-route.test.mjs b/backend/tests/product-route.test.mjs index fc6fc95..cb800e1 100644 --- a/backend/tests/product-route.test.mjs +++ b/backend/tests/product-route.test.mjs @@ -24,6 +24,9 @@ const record = (method, result) => async (...args) => { return typeof result === 'function' ? result(...args) : result; }; const repository = { + listStoreCatalog: record('listStoreCatalog', { + storeId: '11', salesOpen: true, categories: [] + }), listCategories: record('listCategories', []), createCategory: record('createCategory', { categoryId: '31', version: 1 }), updateCategory: record('updateCategory', { categoryId: '31', version: 2 }), @@ -79,6 +82,18 @@ const unauthorized = await app.inject({ assert.equal(unauthorized.statusCode, 401); 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'] }; const forbidden = await app.inject({ method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` } diff --git a/docs/api-changelog/2026-08-11-M09-D4-product-experience.md b/docs/api-changelog/2026-08-11-M09-D4-product-experience.md new file mode 100644 index 0000000..4e45df7 --- /dev/null +++ b/docs/api-changelog/2026-08-11-M09-D4-product-experience.md @@ -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.*` 独立授权,服务端仍是最终权限边界。 diff --git a/docs/current-baseline.md b/docs/current-baseline.md index 79d6eae..8f1933c 100644 --- a/docs/current-baseline.md +++ b/docs/current-baseline.md @@ -1,10 +1,10 @@ # 当前开发成果基线 > V5.0 首次核验日期:2026-06-16 -> audited_commit: `1a3ea7b` -> audited_worktree: M09-D3 当前工作树(待本次中文工程提交) -> audited_at: `2026-08-11T09:30:00+08:00` -> next_engineering_target: 按 V5.7 固定队列执行 M09-D4,接入商品、库存、订单与寄存取出的顾客和运营界面 +> audited_commit: `60264cb` +> audited_worktree: M09-D4 已完成工程(随本次中文工程提交固化) +> audited_at: `2026-08-11T15:10:00+08:00` +> next_engineering_target: 按 V5.7 固定队列执行 M09-REGRESSION,完成商品域全链路对账与模块验收 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 ## 总体结论 @@ -13,9 +13,9 @@ |---|---|---|---| | 总纲版本 | V5.7 已成为当前权威总纲,V5.6 及更早版本仅作历史基线 | 根目录 `V5.7.md` 的增量控制层与第 30 章固定队列 | 必须按 V5.7 游标开发,不得回退到历史队列 | | Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main` 为 `0 0` | 本地与远端同步 | -| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 API;M09-A/B/C 已完成保洁收口,M09-D1/D2/D3 已完成商品目录、库存、订单支付和寄存取出安全生命周期 | `backend/src/products/**`、`backend/src/inventory/**`、`backend/src/routes/{products,inventory,product-orders,product-storages}.ts`、`database/migrations/202608110{7,8,9}_m09d*`、`backend/tests/product-*.test.mjs`、`backend/tests/inventory-*.test.mjs`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M09-D3 当前工作树的 build、全测、静态门禁和真实 MySQL 门禁通过,游标进入 M09-D4;提交 SHA 待本次中文工程提交取得 | -| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;12 个业务入口按路由懒加载;保洁工作台覆盖规则、任务、版本验收、同源结算导出、冲正历史、状态事件和人工付款;商品/库存/商品订单菜单能力由服务端 capability 控制 | `admin/src/router.ts`、`admin/src/components/CleaningWorkspace.vue`、`backend/src/routes/admin-auth.ts` | M09-D2 管理 API 与权限边界通过;商品、库存、订单和寄存专用运营页面统一归入 M09-D4 | -| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路、保洁任务端照片上传/补做主体链路,以及管理员门店/房态/订单/代下单/验券/会员/员工/保洁/设备/经营统计 | `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 | +| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 API;M09-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 权限路由工程;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 生产构建与导航测试通过;桌面/平板/手机响应式样式和一次性领取码提示已纳入静态门禁 | +| 微信小程序 | 已纳管微信原生模板,并完成顾客端房间订单、充值、权益、保洁等主体链路;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;一次性凭证、部分取出、并发全量取出单赢家、取消/过期、隔离和不可变流水通过 | | 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 后接入 | @@ -26,7 +26,7 @@ ## 状态校准 - `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-D4,M10 尚未进入。 +- 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-REGRESSION,M10 尚未进入。 - 任何“目录存在”只能证明骨架存在,不能证明模块完成。 ## Git 历史敏感信息核验 @@ -37,6 +37,6 @@ ## 下一步 -1. 执行 M09-D4:在顾客小程序和运营后台接入商品目录、商品订单、库存与寄存/取出工作台,复用服务端权限、状态机与幂等契约。 -2. 领取凭证只保存哈希,限制本人或授权门店员工领取;分次取出以事务行锁、稳定 requestId 和剩余数量条件阻止重复、超量与并发超领。 -3. 寄存、取出、取消和过期同事务写审计与通知 outbox;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。 +1. 完成 M09-D4 中文提交、push 和远端标题校验。 +2. 执行 M09-REGRESSION,对商品目录、库存、订单支付、退款、寄存数量、权限和界面逐单对账,补模块级缺陷与验收证据。 +3. M06-G 真实硬件、真实微信与团购联调继续等待外部条件,不阻断内部主队列。 diff --git a/docs/devlogs/2026-08-11-M09-D4-商品端到端体验.md b/docs/devlogs/2026-08-11-M09-D4-商品端到端体验.md new file mode 100644 index 0000000..068d5c9 --- /dev/null +++ b/docs/devlogs/2026-08-11-M09-D4-商品端到端体验.md @@ -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.10:PASS,完成 `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`。 diff --git a/docs/feature-status.md b/docs/feature-status.md index 65181a9..0641646 100644 --- a/docs/feature-status.md +++ b/docs/feature-status.md @@ -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-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 | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 | |---|---|---|---|---|---|---|---| @@ -79,10 +81,10 @@ | CLN-003 | 保洁统计 | M08-B/M09 | DONE | `1f10715` | 保洁员与管理端统计统一按有效成员份额复算:免清洁不计奖励,DRAFT/CONFIRMED 仍计未付款,只有 PAID 计已结算;负责人/协作者、任务状态、结算状态、门店、日期和每日趋势口径一致。统计页、任务/结算明细和整数分 CSV 可相互校验;真实 MySQL 覆盖协作成员分步付款和最终任务结算。 | - | M10 以生产数据抽样复算,不重开统计状态机。 | | CLN-004 | 保洁结算 | M09-C | PARTIAL | `1f10715` | 同一任务成员份额由数据库生成唯一键保证最多一条有效结算明细,并发生成只有一个赢家;活动结算冻结成员奖励。取消保留原单/明细并生成负向冲正和状态事件,可重新生成;出款前 CAS、稳定请求号、商户隔离回调、成功重放、失败重试、人工已付、同源列表/导出和整数分均已闭环。协作任务按收款人分别付款,全部有效份额 PAID 后任务才进入 SETTLED。 | 真实微信商户凭据、生产小额转账和回调仍为 BLOCKED_EXTERNAL。 | 取得生产商户条件后执行小额转账、迟到回调和账单对账验收;内部工程不阻断 M09-D1。 | | CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 | -| PRD-001 | 商品目录与门店售卖 | M09-D1 | DONE | `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 寄存数量已使用独立账本,不回写已完成订单项快照。 | -| GOODS-102 | 商品订单与支付库存闭环 | M09-D2 | DONE | `1a3ea7b` | 商品订单与支付单分离;订单项固化商品、SKU、规格、单价、数量和金额快照,状态事件不可变。顾客可下单、查询、取消,授权 STAFF/管理员可接单、配送/待取、完成、取消/退款;房间配送验证本人有效房间订单,自助订单不绑定房间。支付成功扣减、取消/超时释放、退款按履约状态返库均有回调幂等、事务重试、审计和 MySQL 8.4 并发证据。 | - | M09-D4 接入顾客和运营端商品订单界面。 | -| GOODS-104 | 商品寄存与安全取出 | M09-D3 | DONE | M09-D3 当前工作树(待本次中文工程提交) | 支持已完成商品订单或授权员工手工创建寄存,状态覆盖 STORED、PARTIALLY_RETRIEVED、RETRIEVED、EXPIRED、CANCELLED;商品/SKU/数量/会员/门店/到期时间固化。领取凭证仅存 SHA-256,部分取出自动轮换且旧凭证失效;本人或授权员工可领取,逐项数量流水不可变。并发领取行锁、版本 CAS、幂等事件和事务重试保证不超领,所有状态动作写审计与 outbox。 | - | M09-D4 接入顾客寄存详情、凭证操作与运营领取工作台。 | +| GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b` + M09-D4 当前暂存工程 | 商品订单与支付单分离,订单项与状态事件不可变;M09-D4 小程序接入选购、购物车、创建与取消,Vue 后台接入筛选、详情、接单、配送/待取、完成、取消和退款。库存、支付、退款仍由服务端状态机和幂等事务控制。 | 真实微信商品支付入口仍缺生产商户条件。 | M09-REGRESSION 完成模拟支付全链路对账;真实支付纳入 BLOCKED_EXTERNAL。 | +| 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 以真实员工账号验证绑定、门店切换、禁用和重新登录。 | | BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 | | TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 | diff --git a/docs/module-status.md b/docs/module-status.md index 7c0abb7..bf9c065 100644 --- a/docs/module-status.md +++ b/docs/module-status.md @@ -3,16 +3,16 @@ ```yaml execution_cursor: current_module: M09 - current_stage: M09-D4 + current_stage: M09-REGRESSION stage_status: TODO - last_completed_stage: M09-D3 - next_stage: M09-REGRESSION - last_engineering_commit: `1a3ea7b` - pending_engineering_commit: "M09-D3 当前工作树(待本次中文工程提交)" + last_completed_stage: M09-D4 + next_stage: M10-A + last_engineering_commit: `60264cb` + pending_engineering_commit: "M09-D4 已完成工程(随本次中文工程提交固化)" last_push_verified: true base_branch: main blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNAL;M08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。" - updated_at: 2026-08-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。 | | M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 | | M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 | -| M09 保洁任务、结算、商品和库存 | DOING | M09-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 | - | - | - | diff --git a/miniapp/app.json b/miniapp/app.json index 3bbfa93..8fee7b4 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -2,6 +2,7 @@ "pages": [ "pages/index/index", "pages/store/detail", + "pages/goods/index", "pages/room/detail", "pages/orders/list", "pages/orders/detail", diff --git a/miniapp/pages/goods/index.js b/miniapp/pages/goods/index.js new file mode 100644 index 0000000..444063c --- /dev/null +++ b/miniapp/pages/goods/index.js @@ -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(''), + })) +} diff --git a/miniapp/pages/goods/index.json b/miniapp/pages/goods/index.json new file mode 100644 index 0000000..16b1371 --- /dev/null +++ b/miniapp/pages/goods/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "商品与寄存" +} diff --git a/miniapp/pages/goods/index.wxml b/miniapp/pages/goods/index.wxml new file mode 100644 index 0000000..b5d2cde --- /dev/null +++ b/miniapp/pages/goods/index.wxml @@ -0,0 +1,118 @@ + + + + + M09 · 门店增值服务 + 商品与寄存 + + 门店 #{{storeId || '-'}} + + + + 选购 + 商品订单 + 我的寄存 + + + {{errorMessage}} + 正在加载商品服务… + + + {{oneTimeCredentialTitle}} + 领取码仅展示本次,不会写入本地缓存。请立即妥善保存。 + {{oneTimeCredential}} + + + + + + + + 请从门店详情进入商品页,以载入对应门店的商品目录。 + 当前不在商品售卖时段,可查看目录,暂不能提交订单。 + + {{category.name}} + + + + + + {{product.name}} + {{product.unitName}} · {{product.fulfillmentMode === 'DELIVERY' ? '可配送' : '门店自取'}} + + 支持寄存 + + {{product.description}} + + + {{sku.name}} + {{sku.priceText}} + + + + {{sku.selectedQuantity}} + + + + + + + 当前门店暂无在售商品 + + 已选 {{cartQuantity}} 件{{cartTotalText}} + + + + + + + + {{order.orderNo}}{{order.createdText}} + {{order.statusText}} + + {{order.totalQuantity}} 件 · {{order.fulfillmentMode === 'DELIVERY' ? '配送' : '门店自取'}}{{order.amountText}} + + + + + + 暂无商品订单 + + + + + + + {{storage.storageNo}}到期 {{storage.expiresText}} + {{storage.statusText}} + + 剩余 / 总数{{storage.remainingQuantity}} / {{storage.totalQuantity}} + 查看寄存明细与取出记录 › + + 暂无商品寄存 + + + + ‹ 返回寄存列表 + + {{selectedStorage.storageNo}}到期 {{selectedStorage.expiresText}} + {{selectedStorage.statusText}} + + 取出时需输入当前领取码;服务端仅保存不可逆摘要。 + + {{item.productName}} · {{item.skuName}}剩余 {{item.remainingQuantity}} / {{item.totalQuantity}} + + + + + + + 取出流水 + + {{movement.createdText}} · 明细 #{{movement.storageItemId}}取出 {{movement.quantity}},剩余 {{movement.remainingAfter}} + + 暂无取出记录 + + + + diff --git a/miniapp/pages/goods/index.wxss b/miniapp/pages/goods/index.wxss new file mode 100644 index 0000000..adfa7e7 --- /dev/null +++ b/miniapp/pages/goods/index.wxss @@ -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; } diff --git a/miniapp/pages/profile/index.js b/miniapp/pages/profile/index.js index 8988d86..5213511 100644 --- a/miniapp/pages/profile/index.js +++ b/miniapp/pages/profile/index.js @@ -40,6 +40,10 @@ Page({ openBenefits() { wx.navigateTo({ url: '/pages/benefits/index' }) }, + + openGoods() { + wx.navigateTo({ url: '/pages/goods/index?tab=orders' }) + }, }) function formatProfile(profile) { diff --git a/miniapp/pages/profile/index.wxml b/miniapp/pages/profile/index.wxml index 94de561..6fef557 100644 --- a/miniapp/pages/profile/index.wxml +++ b/miniapp/pages/profile/index.wxml @@ -73,6 +73,7 @@ + 最近账单 diff --git a/miniapp/pages/store/detail.js b/miniapp/pages/store/detail.js index 0a25f8d..880e33b 100644 --- a/miniapp/pages/store/detail.js +++ b/miniapp/pages/store/detail.js @@ -44,6 +44,13 @@ Page({ }) }, + openGoods() { + if (!this.data.storeId) return + wx.navigateTo({ + url: `/pages/goods/index?storeId=${encodeURIComponent(this.data.storeId)}`, + }) + }, + async loadWifi() { if (!this.data.storeId) return this.setData({ loading: true, errorMessage: '' }) diff --git a/miniapp/pages/store/detail.wxml b/miniapp/pages/store/detail.wxml index a44b5d5..8582068 100644 --- a/miniapp/pages/store/detail.wxml +++ b/miniapp/pages/store/detail.wxml @@ -8,6 +8,7 @@ 门店详情 {{errorMessage}} + 房间 暂无可预订房间 diff --git a/miniapp/pages/store/detail.wxss b/miniapp/pages/store/detail.wxss index f6d9d40..63bcaea 100644 --- a/miniapp/pages/store/detail.wxss +++ b/miniapp/pages/store/detail.wxss @@ -67,3 +67,9 @@ color: #888888; text-align: center; } + +.goods-entry { + margin: 24rpx 0; + color: #ffffff; + background: #1d4ed8; +} diff --git a/scripts/check-admin-m08-d-r1.mjs b/scripts/check-admin-m08-d-r1.mjs index bd6a194..5edd137 100644 --- a/scripts/check-admin-m08-d-r1.mjs +++ b/scripts/check-admin-m08-d-r1.mjs @@ -24,11 +24,11 @@ if (!distOnly) { assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/); 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']) { 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, /isNavigationAllowed/); @@ -60,7 +60,7 @@ if (!sourceOnly) { .sort((left, right) => right.bytes - left.bytes)[0]; assert.ok(largest.bytes < 500 * 1024, `chunk ${largest.name} exceeds 500 KiB`); for (const chunk of ['OperationsOverviewPanel', 'StoresRoomsPanel', 'OrdersPanel', - 'PaymentsPanel', 'ThirdPartyPanel', 'MembersStaffPanel', 'CleaningWorkspace', + 'ProductOperationsPanel', 'PaymentsPanel', 'ThirdPartyPanel', 'MembersStaffPanel', 'CleaningWorkspace', 'DevicesPanel', 'PlatformAppsPanel', 'ContentManagementPanel', 'FranchisePanel', 'LogsSystemPanel']) assert.ok(javascript.some((name) => name.startsWith(`${chunk}-`)), `${chunk} is not lazy`); const entry = javascript.find((name) => name.startsWith('index-')); diff --git a/scripts/check-admin-m08-d.mjs b/scripts/check-admin-m08-d.mjs index e2029f2..80be995 100644 --- a/scripts/check-admin-m08-d.mjs +++ b/scripts/check-admin-m08-d.mjs @@ -24,10 +24,10 @@ for (const pattern of [ assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/); 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/); 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}'`)); const cleaningWorkspace = read('admin/src/components/CleaningWorkspace.vue'); diff --git a/scripts/check-m09-d4-product-experience.mjs b/scripts/check-m09-d4-product-experience.mjs new file mode 100644 index 0000000..6a88294 --- /dev/null +++ b/scripts/check-m09-d4-product-experience.mjs @@ -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.' +); diff --git a/scripts/dev/windows/test-all.ps1 b/scripts/dev/windows/test-all.ps1 index 7638abf..a28774a 100644 --- a/scripts/dev/windows/test-all.ps1 +++ b/scripts/dev/windows/test-all.ps1 @@ -44,6 +44,8 @@ node scripts/check-m09-d2-product-orders.mjs Assert-NativeSuccess "check-m09-d2-product-orders" node scripts/check-m09-d3-product-storage.mjs 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") { Push-Location admin