Files
dealerhub/PROGRESS_BATCH_D.md
T

245 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)<br>**进项**:借 存货 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,服装选辅助属性,建材选套装拆件)。