245 lines
13 KiB
Markdown
245 lines
13 KiB
Markdown
# 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,服装选辅助属性,建材选套装拆件)。
|