# dealerhub · 批次 D 实施报告(订货商城 / 税率 / 打印扩展) > **依据**:《后续改造实施计划.md》批次 D(P3 多端与行业扩展)+ C1 收尾。 > **结果**:D1(B2B 订货商城)与 D4(税率)落地,D5(打印扩展)提前完成; > 全量回归 **346 passed**(批次 C 后 300 → 新增 46),`check` 0 issues,无待生成迁移。 > **未做**:D2(业务员移动开单,需 D1 框架沉淀后再上)、D3(SN/辅助属性,按客户驱动)。 --- ## 零、C1 收尾 · 套餐到期 cron 接线 `config/dramatiq.py` 新增 `cron_daily_billing_expiry`(每日 03:00)→ `expire_trials_and_notify` actor。 此前 `billing.tasks.expire_trials()` 只有函数没有调度入口,**试用永不到期**——这是个会漏钱的缺口。 测试 `test_billing_expiry_cron_registered` / `test_scheduler_tick_billing_expiry_runs` 守住。 --- ## 一、D1 · B2B 订货商城(新增 `apps/storefront/`) ### 设计要点 | 项 | 做法 | |---|---| | 客户身份 | `StorefrontAccount`(手机号 + PBKDF2 口令,**不占 billing 的 users 配额**);会话用 HMAC 签名 token(`account_id.expires.sig`),无状态、省一张表 | | 商品可见性 | **白名单** `CustomerProductAuth`:默认不可见,授予才可见(比黑名单安全——漏配不会误暴露) | | 价格 | 复用 `partner.services.quote_price()`(客户专属价 → 等级价 → 最近成交价 → 默认价),**不在商城侧另立价格体系** | | 多单位 | 复用 `UnitConversion`,按箱下单自动折基本单位、单价按箱价 | | 下单 | 生成 `StorefrontOrder`(快照价格),**不直接过账** | | 额度 | 提交即预检 `credit_usage`,超限返回 **402**(不等内部确认才发现) | | 内部流转 | `confirm_order` → 生成 **SalesOrder 草稿**(`state=draft`,需人工过账);`reject_order` 带原因 | | 套餐门槛 | 商城是专业版功能:走 `billing.quota.check_and_count(tenant, "storefront")` | ### API **客户端**(公开鉴权走 `Authorization: Storefront `) ``` POST /api/v1/storefront/login/ {phone, password, tenant_code} → token GET /api/v1/storefront/catalog/ 可见商品目录(含商城价/来源/单位) GET /api/v1/storefront/orders/ 我的订单 POST /api/v1/storefront/orders/ {lines:[{product_id,quantity,source_unit?}], remark} ``` **内部端**(后台 JWT) ``` GET /api/v1/storefront/admin/orders/?status=submitted|confirmed|rejected|all POST /api/v1/storefront/admin/orders//confirm/ 确认 → SalesOrder 草稿(自动选默认仓库) POST /api/v1/storefront/admin/orders//reject/ 驳回(带原因,记入 remark) GET|POST /api/v1/storefront/admin/accounts/ 商城账号管理 GET|POST|DELETE /api/v1/storefront/admin/auths/ 商品授权(授/撤) ``` ### 前端 - **H5 订货页** `/#/storefront`(移动优先,480px 断点):登录 → 商品卡片(商城价 + 单位下拉 + 数量)→ 购物车(合计 + 备注)→ 提交 → 我的订单(状态标签) - **内部管理页** `/#/storefront-admin`:商城订单(展开看明细 + 确认/驳回)、商城账号开通、商品授权(多选授予 + 撤销) - 演示账套 `seed_demo` 顺带开通 5 个商城账号 + 20 条商品授权(密码 `store12345`),并给演示租户挂**专业版**订阅(否则商城被功能开关拦) ### 验证(Playwright 真实浏览器 + API 全链路) | 场景 | 结果 | |---|---| | 客户登录(13600000001 / sf123456) | 200 + token,显示客户名 | | 商品目录 | 只显示已授权的 SFP01(未授权完全不可见) | | 加购物车 5 件 | 5 × ¥20 = **¥100.00**(价格显示 bug 已修,见踩坑 2) | | 提交订单 | `HD202609100001` 已提交待确认 | | 内部确认 | 生成 `SO202609100001` **草稿**,金额 60.00,行 3 × 20.00 | | 我的订单列表 | 同时显示"已提交待确认"与"已确认转单"两种状态 | | 未授权商品下单 | **403** `product_not_authorized` | | 额度超限下单 | **402** `credit_limit_exceeded`(订单不落库) | | 免费版租户登录商城 | **403** `quota_exceeded`(kind=storefront,引导升级) | --- ## 二、D4 · 税率(价内口径 + 凭证拆分) ### 口径决策 **只做税率,不做多币种**(计划要求:多币种无跨境客户前不做,从话术删除)。 采用**价内税**(国内批发/零售单据惯例:报价即含税价): ``` net = amount / (1 + rate) tax = amount - net ``` 实测:`113 @13% → 净 100.00 + 税 13.00`;`100 @13% → 净 88.4956 + 税 11.5044`(无尾差,`net+tax == gross` 有专项测试)。 ### 落点 | 层 | 变更 | |---|---| | `catalog.Product` | `tax_rate`(默认 0.13,可按商品覆盖) | | `sales.SalesBillLine` / `purchase.PurchaseBillLine` | `tax_rate` + `tax_amount`(**行级快照**,事后改商品税率不影响历史单) | | `core.services` | `compute_tax()` 价内拆分、`resolve_tax_rate()` 行 > 商品 > 0 | | 开单服务 | 行落税率/税额;`unit_price`/`amount` 语义不变(仍是含税) | | 凭证 | **销项**:借 应收 113 / 贷 收入 100 + 贷 销项税 13(2221)
**进项**:借 存货 100 + 借 进项税 13(2222)/ 贷 应付 113 | | 抹零 | 在税后:行金额含税 → 合计 → 抹零 → 净额入应收;行税额仍按未抹零的含税金额拆分 | | 报表 | 利润表营业收入 = 6001 贷方 = **不含税净额**;资产负债表仍平衡(有专项测试) | | 前端 | 开单页行内显示"13% / 税 ¥x.xx",合计区显示"其中税额 ¥x(不含税 ¥y)";列表单显示"含税,税额 x";商品页显示税率 | | 兼容 | 税率 0 时不产生税费分录,与 D4 之前**完全一致**(老测试零改动通过) | ### 验证 25 个专项测试:拆分数学(含 4 组参数化)、税率解析优先级、开单落库、抹零交互、 凭证借贷平衡与科目正确、零税率回归、利润表口径、资产负债表平衡、API 字段。 --- ## 三、D5 · 打印扩展(提前完成) ### 1. 58mm 热敏小票 新外壳 `RECEIPT_SHELL`(`@page { size: 58mm auto }`,等宽字体、虚线分隔、大号合计), 模板 `xs-receipt-58`。适合前台小票机直接打印。 ### 2. 三联送货单 新外壳 `TRIPLICATE_SHELL`(A4 横向三栏 flex),模板 `xs-triplicate`, 把同一单据复制成 **①存根联 / ②客户联 / ③财务联**,每栏独立签收栏。 ### 3. 对账单二维码(串起 A 批次的对账单分享) 纯 Python 实现 **QR Code Model 2**(字节模式 / 纠错 L / 自动选版本 / 固定掩码), 输出 SVG data-uri——**零第三方依赖**,无外部请求。 销售单尾部自动印二维码(复用最近一条有效分享,无则新建 90 天有效期), 客户扫码直接打开 H5 对账单页面。 ### 4. 版式选择(前端) 销售单列表"打印 ▾"下拉:A4 销售单 / 58mm 小票 / 三联送货单。 ### 二维码正确性验证(本轮最有价值的一环) 写了一个**往返自检** `apps/printing/qr_selftest.py`(解码器只覆盖生成器用到的子集): 19 个测试用例要求"生成 → 解码必须完全一致",含中文、90 字符长 URL、单字符、60 字符填充。 **这一步抓出了一个真 bug**:早期版本生成的二维码肉眼看着完全正常,但**扫不出正确内容**—— 位流解析按字节边界取 payload,而 QR 的"4 位模式 + 8 位长度 = 12 位头"是跨字节的,导致整体错位。 修复后 `6/6` 往返通过。若没有这个自检,就会交付一个"像二维码但扫不出来"的假成功。 端到端测试更进一步:单据 HTML → 抽出二维码 → 解码出 URL → **匿名访问该 URL 得 200**。 --- ## 四、验证汇总 ### 后端 ``` $ python -m pytest tests/ -p no:cacheprovider 346 passed in 11.64s # 批次 C 后 300 → +46(D1 30 + D4 25 中 22 + D5 19,含重叠) $ python manage.py check System check identified no issues (0 silenced). $ python manage.py makemigrations --check --dry-run No changes detected $ python -m apps.printing.qr_selftest 往返测试:6/6 通过 ``` ### 前端 ``` $ npm run build ✓ built in 6.73s dist/assets/Storefront-*.js 9.71 kB ← H5 订货 dist/assets/StorefrontAdmin-*.js 8.03 kB ← 内部管理 dist/assets/SalesBills-*.js 16.75 kB ← +税率列 +打印版式下拉 ``` ### 浏览器实测 订货商城完整链路(登录 → 目录 → 购物车 → 下单 → 内部确认 → 销售订单草稿)、 打印三种版式、对账单二维码匿名可访问,全部通过。 --- ## 五、变更文件清单 ``` backend/ ├── apps/storefront/ [新] B2B 订货商城 │ ├── models.py StorefrontAccount / CustomerProductAuth / StorefrontOrder(+Line) + HMAC token │ ├── services.py 白名单目录 / 复用取价 / 下单校验 / 转 SalesOrder 草稿 / 演示种子 │ ├── views.py·urls.py 客户端 3 端点 + 内部 5 端点 │ └── migrations/0001_initial.py ├── apps/printing/qr.py [新] 纯 Python QR 生成(SVG data-uri) ├── apps/printing/qr_selftest.py [新] 往返自检(解码器子集) ├── apps/printing/services.py [改] +58mm/三联外壳与模板 +二维码注入 ├── apps/printing/renderer.py [改] 嵌套 each 支持(最内层优先展开) ├── apps/catalog/models.py·serializers.py [改] +Product.tax_rate ├── apps/sales/models.py·services.py·serializers.py [改] 行税率/税额 + tax_total ├── apps/purchase/models.py·services.py·serializers.py [改] 同上 ├── apps/core/services.py [改] +compute_tax / resolve_tax_rate ├── apps/finance/services.py [改] 凭证拆销项(2221)/进项(2222) ├── apps/catalog/views.py [改] units 接口暴露 tax_rate ├── config/dramatiq.py [改] +套餐到期 cron(C1 收尾) ├── apps/billing/tasks.py [改] +dramatiq actor 包装 ├── apps/core/management/commands/seed_demo.py [改] +商城账号/授权 +专业版订阅 └── tests/ test_storefront.py(30) · test_tax.py(25) · test_printing_extended.py(19) · test_billing.py(+2) frontend/src/ ├── pages/Storefront.vue [新] H5 订货(移动优先) ├── pages/StorefrontAdmin.vue [新] 商城订单/账号/授权管理 ├── api/storefront.js [新] 商城专用 axios(session token) ├── pages/SalesBills.vue [改] +税率列 +打印版式下拉 ├── pages/Products.vue [改] +税率列 ├── router/index.js [改] +/storefront(公开)/storefront-admin └── layouts/MainLayout.vue [改] +商城订单菜单 frontend/dist/ [重建] ``` --- ## 六、踩坑记录 1. **DRF 全局 `IsAuthenticated` 会拦住自定义鉴权端点**:商城客户端要用 `Authorization: Storefront `, 必须在视图上加 `@authentication_classes([]) @permission_classes([AllowAny])` 自己管鉴权,否则被 JWT 挡在门外。 2. **前端模板里调用带副作用的函数 = 渲染地狱**:商城页最初用 `cartOf(productId)` 在模板里读写购物车, 每次渲染都会新建行、`price` 未初始化 → 显示 ¥0.00。改为 `reactive` 状态表(`lineOf(p)` 纯读)后正常。 3. **渲染器 `item` 只有一层作用域**:三联送货单是"循环套循环",内层 `{{item.lines}}` 里的 `item` 会被外层覆盖。 解法:在 Python 侧预先把行渲染成 HTML,模板只留一层循环(同时避免了改渲染器的复杂度)。 4. **`{{#each}}` 的变量替换必须就地完成**:把变量替换推迟到"所有循环展开之后", `item` 已不在 context,`{{item.xxx}}` 全部渲染成空——每轮迭代内必须完成 if + 变量替换。 5. **演示租户的只读中间件会拦商城下单**:加了前缀白名单放行客户端端点(后台端确认/授权仍被拦), 这样客户能在演示账套里体验完整"下单 → 业务员确认"闭环。 6. **Windows 端口/进程**:Granian 旧进程 socket 残留会导致"改了没生效"(旧实例抢答), 换端口最省心;`netstat -ano | grep :PORT` 确认只有一个 PID 再验证。 7. **shell heredoc 陷阱**:Git Bash 下用 heredoc 写含中文/嵌套引号的 Python 文件会被截断, 改用 Write 工具或 Python 脚本写文件。 --- ## 七、批次 D 状态 | 项 | 计划 | 状态 | |---|---|---| | D1 B2B 订货商城 H5 | 客户授权可见 + 等级价 + 自助下单草稿 + 接配额 | ✅ 完成 | | D2 业务员移动开单 | H5 复用 D1 框架 + A1 取价组件 | ⏳ 未开始(D1 框架已就位,工作量小) | | D3 行业专版 | SN 序列号 / 辅助属性 / 套装拆件 | ⏳ 按客户驱动(计划中即为"按拿下客户顺序做") | | D4 税率 | 价内/价外 + 凭证 + 报表;多币种从话术删除 | ✅ 完成(仅税率,多币种已弃) | | D5 打印扩展 | 三联 / 58mm 小票 / 对账单二维码 | ✅ 完成 | **建议下一步**: 1. **先上线 A/B/C/D 成果到 192.168.5.7**(镜像重建),拿真实客户反馈再决定 D2/D3 优先级; 2. D2 移动开单可复用 D1 的 H5 外壳 + A1 的取价/单位/抹零组件,预计 2–3 天; 3. D3 按第一个付费客户的行业选一个做(3C 选 SN,服装选辅助属性,建材选套装拆件)。