零售供货包(retail)v1 设计 — 波波 2026-09-24

与住宿包(hotel)、餐饮包(restaurant)同构的第三个行业增量包。
定位:商户自营进销存 + 线下收银。对应 084 feature_catalog 的 retail(标签「零售供货包」,sort_order 40)。

一、开工前事实核查(以库/代码为证,非凭印象)

核查项结论依据
复用 008 orders?不复用order_type CHECK 仅 package/room/ticket/voucher_bundle;user_id NOT NULL。零售线下收银单多为无登录用户的柜台单,塞不进统一订单域
复用 inventory.py?不复用WIP-311 平台库存域:admin_router 写 + C端只读 availability,面向 package/component/route/guide 资源日历,非商户 SKU 进销存
复用 miniapp_products.py?不复用C端旅行套餐适配层,映射老库 packages/package_components,与零售 SKU 无关
表名冲突?无prod 库 pg_tables 中 %product%/%retail%/%stock%/%goods% 零命中
SQL 编号用 091migrate.sh 以 filename 为 PK(非编号);现存 090_deposits 与 090_restaurant 已重号且无害,但不再新增重号
B端零售页现状占位WIP-147「诚实降级」4 页(SupplierProducts/ProductEdit/SupplierApply/SupplierQuestionnaire),零接口、按钮 disabled、文案「接口未开放」
门禁现状已注册business-pack.ts PACK_EXTRA_ROUTES.retail 已含 4 路由;feature_catalog 已有 retail 行 → A端人工开通即可见(09-23 义父定调:不按 business_type 自动锁死)

二、范围切分(本次做 / 本次不做)

✅ 本次做(零售域本体)

路由页面职责
/supplier-productsSupplierProducts(改造)商品列表 + 搜索 + 上下架 + 低库存标记
/product-editProductEdit(改造)商品新增/编辑(品类改为零售品类)
/retail-stockRetailStock(新增)进货/出货/盘点 + 库存流水
/retail-ordersRetailOrders(新增)收银开单 + 结账 + 取消回滚库存
/retail-dashboardRetailDashboard(新增)今日单量/营业额/SKU 数/低库存预警

❌ 本次不做(已识别问题,单独上报,不混入零售域)

路由原因
/supplier-apply语义错位:入驻申请是「成为商户之前」的动作,而 B端工作台用户已是商户。后端 miniapp_merchant_applications.py 已完整实现 C端提交 + 运营端审核流(pending→reviewing→approved 自动建 merchants 行)。该页应由 C端/游客侧承接,不属零售包域
/supplier-questionnaire同上,且后端零问卷资产(全仓无 questionnaire)。属独立问卷域,需先定业务归属再建
这两页保留在 PACK_EXTRA_ROUTES.retail 内不动(避免破坏门禁与既有链接),但本次不接真。处置建议见文末「待义父裁定」。

三、数据模型(SQL 091,全幂等 IF NOT EXISTS)

价格沿用全仓 *_fen 约定(入参元 → *100,出参 _yuan())。所有表带 is_test 以隔离测试数据;merchant_id 参数化,不写死商户身份。

1. retail_products — 商品(SKU)

字段类型说明
idBIGSERIAL PK
merchant_idBIGINT NOT NULL FK→merchants ON DELETE CASCADE
nameVARCHAR(64) NOT NULL商品名
descriptionTEXT DEFAULT ''
categoryVARCHAR(32) DEFAULT '未分类'零售品类(见 §五)
price_fenINTEGER ≥0售价
cost_fenINTEGER ≥0成本价(毛利核算用,B端可见、C端不出)
unitVARCHAR(16) DEFAULT '件'计量单位(件/盒/袋/瓶/斤)
image_urlVARCHAR(512) DEFAULT ''
stockINTEGER ≥0 DEFAULT 0当前库存
stock_warnINTEGER ≥0 DEFAULT 0低库存预警阈值
barcodeVARCHAR(64) DEFAULT ''条码(可空,扫码收银预留)
statusVARCHAR(16) DEFAULT 'onsale'onsale/offself/soldout
sort_orderINTEGER DEFAULT 0
is_testBOOLEAN DEFAULT FALSE
created_at/updated_atTIMESTAMPTZ DEFAULT now()

索引:(merchant_id, sort_order)、(merchant_id, status);唯一:(merchant_id, barcode) WHERE barcode <> ''(部分唯一索引,避免空条码互斥)。

2. retail_stock_logs — 库存流水(审计,只增不改)

字段说明
id / product_id FK→retail_products CASCADE / merchant_id
change_typepurchase(进货) / sale(销售出库) / adjust(盘点调整) / refund(退货入库) / cancel(撤单回滚)
qty_changeINTEGER,带符号(进货 +N,销售 −N)
before_stock / after_stockINTEGER ≥0,前后快照(对账用)
ref_order_idBIGINT NULL,关联 retail_orders.id
operator_idBIGINT NULL,操作人 user_id
noteVARCHAR(255) DEFAULT ''
is_test / created_at

索引:(product_id, created_at DESC)、(merchant_id, created_at DESC)。

3. retail_orders — 零售订单(收银单)

字段说明
id / merchant_id FK CASCADE
order_noVARCHAR(32) NOT NULL,唯一 (merchant_id, order_no);生成规则 T{YYYYMMDD}{6位大写hex}(餐饮 R / 零售 T 区分域)
statuscreated(待结账) / paid(已结账) / cancelled(已取消) / refunded(已退款)
total_fen / paid_fenINTEGER ≥0
pay_methodcash / wechat / alipay / other,DEFAULT cash
customer_idBIGINT NULL,可关联既有商户级 guests 表(复用,不重建)
operator_idBIGINT NULL
sourceonsite(柜台) / miniapp(线上),DEFAULT onsite
noteVARCHAR(255) DEFAULT ''
paid_atTIMESTAMPTZ NULL
is_test / created_at / updated_at

索引:(merchant_id, created_at DESC)、(merchant_id, status)。

4. retail_order_items — 订单明细(下单时快照,商品改价不影响历史单)

字段说明
id / order_id FK→retail_orders CASCADE
product_idBIGINT NULL(商品软删后仍留痕)
nameVARCHAR(64) 快照
price_fenINTEGER 快照单价
qtyINTEGER ≥1
subtotal_fenINTEGER = price_fen × qty
is_test / created_at

索引:(order_id)。

跨域兼容(单向引用,零售不反向依赖其他业态)

与餐饮 room_meal_plans→menu_items 同一思路:不新建连接表,改为暴露只读目录接口供其他业态引用:

若后续住宿/餐饮需要在自己域内固化「本房型配哪些零售商品」,再由调用方建绑定表(如 room_retail_plans),零售侧零改动。这样耦合方向始终单向。

四、接口清单

B端 console_router = /api/miniapp/merchant-console/retail

鉴权:_staff(pool, user) —— 查 merchant_staff 中 status='active' 行取 merchant_id,非商户成员 403(与 hotel/restaurant 完全一致,老板/员工两级均放行)。

商品

方法路径说明
GET/products列表;过滤 status/category/keyword,分页 limit/offset;出参 price/cost 为元、含 stock/stock_warn/low_stock 布尔
POST/products新增;name 必填否则 422
PUT/products/{id}编辑;非本商户 404
DELETE/products/{id}软删(status='offself'),保留历史订单引用

库存

方法路径说明
POST/products/{id}/stock入参 {change_type, qty, note};purchase/refund 为正、sale/adjust 可负;事务内 SELECT ... FOR UPDATE 锁行 → 校验 after_stock ≥ 0(不足 409)→ 更新 stock → 写 retail_stock_logs;库存归零自动 status='soldout'
GET/products/{id}/stock-logs单品流水,分页
GET/stock-logs全商户流水,分页

订单

方法路径说明
GET/orders列表;过滤 status、days(默认 7)、分页
GET/orders/{id}详情含 items
POST/orders开单;items 为空 422;事务内逐品校验在售 + 扣库存(不足 409 整单回滚)+ 写明细 + 算 total_fen + 写 sale 流水
PUT/orders/{id}/status改状态;paid/cancelled/refunded 校验;cancelled/refunded 回滚库存并写 cancel/refund 流水;已终态不可再改(409)
POST/orders/{id}/settle结账;入参 pay_method、可选 paid_fen;paid_fen > total_fen → 422;写 paid_fen/paid_at/status='paid'

概览

方法路径说明
GET/dashboardtoday_orders(今日 paid 单量)、today_revenue(今日 paid_fen 合计)、sku_total、sku_onsale、low_stock_count(stock ≤ stock_warn AND status<>'offself')、open_orders(未结账)

C端 miniapp_router = /api/miniapp

方法路径说明
GET/retail/products只读商品目录;merchant_id 必填(缺失 422);商户不存在 404;仅返 status='onsale';不暴露 cost_fen(成本是商户机密)

错误码约定

400 参数格式 / 401 未认证 / 403 非商户成员 / 404 资源不存在或跨商户 / 409 状态冲突·库存不足 / 422 业务校验失败。零 mock、零占位——库存不足即 409,不做「假装成功」。


五、品类(零售,替换 WIP-147 占位的旅行品类)

ProductEdit 原选项为 餐饮/住宿/交通/门票/导游 —— 属旅行套餐语境残留,与零售 SKU 无关,本次替换为:

valuelabel
specialty特产
food食品
drink饮品
craft文创手作
daily日用
other其他
category 字段为 VARCHAR(32) 自由文本(与餐饮 menu_items.category 一致),不做 DB 层 CHECK 约束 —— 便于商户自定义品类,前端用上述枚举做选择器默认值。

六、并发与一致性纪律

  1. 扣库存必须在事务内 + 行锁:SELECT stock FROM retail_products WHERE id=$1 AND merchant_id=$2 FOR UPDATE,否则并发开单会超卖。
  2. 库存不足即整单回滚:任一商品不足 → raise HTTPException(409),事务上下文自动回滚已写入的其他明细与流水。
  3. 流水前后快照自洽:after_stock = before_stock + qty_change,且 CHECK (after_stock >= 0),事后可用 SQL 全量对账。
  4. 终态不可逆:paid/cancelled/refunded 之间仅允许 paid → refunded(退款),其余转移 409。
  5. 金额单位:DB 一律 _fen;接口出参元、入参元(内部 *100)。paid_fen 入参保留分单位(与餐饮 settle 一致)。

七、部署与验证清单


八、待义父裁定(不阻塞本次开发)

  1. /supplier-apply 与 /supplier-questionnaire 的去留:二者是入驻前页面,出现在商户工作台属语义错位。建议从 retail 包移出、归入 C端游客侧(后端 miniapp_merchant_applications.py 已就绪,只差 C端页面接线)。是否照此调整门禁?
  2. 零售商品是否需要 C端下单闭环:本次仅出 C端只读目录。若要让游客在小程序直接买特产(走线上支付),需接入既有 orders/支付链路,属独立工作量,建议另开任务。
  3. 条码扫码收银:barcode 字段已预留(部分唯一索引),但 B端扫码枪/H5 摄像头扫码未做。是否需要 v1.1 补?

九、开工中发现的 P0 生产风险(跨域,超出零售包范围)

零售包冒烟时意外暴露一个全站性缺陷,已修复并 staging 验证:

缺陷:多商户用户 merchant_id 随机串户

hotel/restaurant/retail/feature_entitlements/auth 五处各自写:

SELECT merchant_id, role FROM merchant_staff WHERE user_id=$1 AND status='active' LIMIT 1

均无 ORDER BY。PostgreSQL 对无序 LIMIT 返回行不保证稳定 → 同一用户不同请求可能落到不同商户 ⇒ 房型/菜品/商品/工作台凭证串户写入。

修复(commit 94a5dc51)

附带修复:is_test 数据隔离缺口

is_test 列此前从未被写入(餐饮/酒店同样缺口)→ C端 WHERE is_test=FALSE 过滤形同虚设、清理测试数据无据。零售域全部 5 处 INSERT 补齐 is_test 继承(订单头继承商户、明细/流水继承商品)。staging 反证:is_test=t 时 C端查不到,临时改 f 后查得到且不泄 cost/stock。

遗留(跨域未动,待裁定)