13 KiB
dealerhub · 批次 D 实施报告(订货商城 / 税率 / 打印扩展)
依据:《后续改造实施计划.md》批次 D(P3 多端与行业扩展)+ C1 收尾。 结果:D1(B2B 订货商城)与 D4(税率)落地,D5(打印扩展)提前完成; 全量回归 346 passed(批次 C 后 300 → 新增 46),
check0 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 <token>)
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/<id>/confirm/ 确认 → SalesOrder 草稿(自动选默认仓库)
POST /api/v1/storefront/admin/orders/<id>/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/ [重建]
六、踩坑记录
- DRF 全局
IsAuthenticated会拦住自定义鉴权端点:商城客户端要用Authorization: Storefront <token>, 必须在视图上加@authentication_classes([]) @permission_classes([AllowAny])自己管鉴权,否则被 JWT 挡在门外。 - 前端模板里调用带副作用的函数 = 渲染地狱:商城页最初用
cartOf(productId)在模板里读写购物车, 每次渲染都会新建行、price未初始化 → 显示 ¥0.00。改为reactive状态表(lineOf(p)纯读)后正常。 - 渲染器
item只有一层作用域:三联送货单是"循环套循环",内层{{item.lines}}里的item会被外层覆盖。 解法:在 Python 侧预先把行渲染成 HTML,模板只留一层循环(同时避免了改渲染器的复杂度)。 {{#each}}的变量替换必须就地完成:把变量替换推迟到"所有循环展开之后",item已不在 context,{{item.xxx}}全部渲染成空——每轮迭代内必须完成 if + 变量替换。- 演示租户的只读中间件会拦商城下单:加了前缀白名单放行客户端端点(后台端确认/授权仍被拦), 这样客户能在演示账套里体验完整"下单 → 业务员确认"闭环。
- Windows 端口/进程:Granian 旧进程 socket 残留会导致"改了没生效"(旧实例抢答),
换端口最省心;
netstat -ano | grep :PORT确认只有一个 PID 再验证。 - shell heredoc 陷阱:Git Bash 下用 heredoc 写含中文/嵌套引号的 Python 文件会被截断, 改用 Write 工具或 Python 脚本写文件。
七、批次 D 状态
| 项 | 计划 | 状态 |
|---|---|---|
| D1 B2B 订货商城 H5 | 客户授权可见 + 等级价 + 自助下单草稿 + 接配额 | ✅ 完成 |
| D2 业务员移动开单 | H5 复用 D1 框架 + A1 取价组件 | ⏳ 未开始(D1 框架已就位,工作量小) |
| D3 行业专版 | SN 序列号 / 辅助属性 / 套装拆件 | ⏳ 按客户驱动(计划中即为"按拿下客户顺序做") |
| D4 税率 | 价内/价外 + 凭证 + 报表;多币种从话术删除 | ✅ 完成(仅税率,多币种已弃) |
| D5 打印扩展 | 三联 / 58mm 小票 / 对账单二维码 | ✅ 完成 |
建议下一步:
- 先上线 A/B/C/D 成果到 192.168.5.7(镜像重建),拿真实客户反馈再决定 D2/D3 优先级;
- D2 移动开单可复用 D1 的 H5 外壳 + A1 的取价/单位/抹零组件,预计 2–3 天;
- D3 按第一个付费客户的行业选一个做(3C 选 SN,服装选辅助属性,建材选套装拆件)。