Files
dealerhub/PROGRESS_BATCH_D.md

13 KiB
Raw Permalink Blame History

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 <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/                  [重建]

六、踩坑记录

  1. DRF 全局 IsAuthenticated 会拦住自定义鉴权端点:商城客户端要用 Authorization: Storefront <token>, 必须在视图上加 @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,服装选辅助属性,建材选套装拆件)。