164 lines
12 KiB
Markdown
164 lines
12 KiB
Markdown
# dealerhub · 批次 A 实施报告(P0 引擎前端配套)
|
||
|
||
> **依据**:`docs/后续改造实施计划.md` 批次 A(A1–A5)+ 全局验收标准。
|
||
> **目标**:把已完成的 P0 后端六项引擎(批次效期 / 多单位 / 自动取价 / 最低售价+抹零 /
|
||
> 信用额度 / 账龄+对账单)从"后端就绪"变成"前端可见可用"。
|
||
> **结果**:全部落地,新增 17 个后端 API 测试(**160 passed**,原 143 零回归),
|
||
> `manage.py check` 0 issues,`npm run build` 通过,Playwright 真实浏览器全链路验证通过。
|
||
> **顺带修掉 2 个真 bug**(详见 §6)。
|
||
|
||
---
|
||
|
||
## 一、后端补口(A 批次唯一需要的 API 侧改动)
|
||
|
||
| 新增接口 | 用途 | 落点 |
|
||
|---|---|---|
|
||
| `GET /catalog/products/<id>/units/` | 商品可用录入单位 + 换算率 + 折算售价(含基本单位),供开单页"选择录入单位" | `apps/catalog/views.py` |
|
||
| `POST /sales/bills/create-bill/` | 按行解析单位/取价/抹零后建草稿单;余额不足返回 400 `insufficient_stock` | `apps/sales/views.py` |
|
||
| `POST /sales/bills/<id>/confirm/` | 过账;**402 + `code=credit_limit_exceeded`** 可前端弹"强制过账",`force=true` 放行 | 同上 |
|
||
| `POST /purchase/bills/create-bill/` | 建草稿(支持批次号/生产日/到期日/录入单位) | `apps/purchase/views.py` |
|
||
| `POST /purchase/bills/<id>/confirm/` | 过账(写库存 + 批次账 + 应付) | 同上 |
|
||
| `POST /finance/statements/share/<token>/revoke/` | 吊销对账单分享链接 | `apps/finance/views.py` |
|
||
| `GET /open/statements/<token>/`(增强) | 浏览器/`?view=html` → 客户侧**可打印 HTML 页面**;默认仍 JSON | `apps/finance/views.py` + `statement_html.py`[新] |
|
||
|
||
**业务异常 → HTTP 语义映射**(前端可判别,不再吞成 500):
|
||
|
||
| 服务层异常 | HTTP | 响应体 |
|
||
|---|---|---|
|
||
| `CreditLimitExceeded` | 402 | `{code, detail, customer_code, outstanding, limit, this_bill}` |
|
||
| `BelowMinPrice` | 400 | `{code: "below_min_price", product_code, unit_price, min_price}` |
|
||
| `InsufficientStock` | 400 | `{code: "insufficient_stock", detail}` |
|
||
| 状态非法 | 400 | `{code: "invalid_state", detail}` |
|
||
|
||
**序列化器补字段**:`ProductSerializer` 增 `base_unit/base_unit_name/min_sale_price/is_batch_managed/shelf_life_days`;
|
||
`SalesBillSerializer` 增 `customer_name/round_off`,行增 `source_unit/source_quantity/unit_name`;
|
||
`PurchaseBillSerializer` 增 `supplier_name`,行增批次三字段 + `unit_name/is_batch_managed`。
|
||
|
||
## 二、A1 销售开单页(`SalesBills.vue`,重写)
|
||
|
||
- **自动取价预填**:选客户 → 逐行调 `price-quote`,行内显示来源标签(客户专属价/等级价/上次成交价/默认售价)+ `上次 ¥x` + `历史区间 min~max`;单价可改。
|
||
- **多单位录入**:行内单位下拉列 `瓶/箱×24/提×6`;切换单位按 rate 重算单价,数量按录入单位填,提交带 `source_unit`。
|
||
- **抹零**:单据级下拉(不抹/分/角/元),底部实时"行合计 → 抹零 -x → 应收合计"三行预览。
|
||
- **最低售价防呆**:成交价折算低于 `min_sale_price` 时单价框描橙,保存前弹"审批放行"确认,确认即行带 `allow_below_min=true`;点"返回修改"则中止。
|
||
- **信用额度**:选客户即显进度条(已用/总额度/可用),≥80% 橙、≥100% 红;过账遇 402 弹"信用额度超限 → 强制过账?"确认框走 `force=true`。
|
||
- **列表**:新增"新建销售单"入口;草稿行带"过账"按钮;金额列显示 `(抹 x.xx)`;已过账可打印。
|
||
|
||
## 三、A2 客户档案页(`Customers.vue`[新])
|
||
|
||
- 表格列:编码/名称/联系人/电话/信用额度/价格等级/状态。
|
||
- **行展开**:左半 = 额度占用进度条(<80% 绿 / 80–99% 橙 / ≥100% 红 + 文案"已超限,赊销将被拦截(可强制放行并记录预警)");右半 = 账龄六桶表 + 占比 + "合计未结"。
|
||
- 路由 `/customers`,侧栏"进销存业务 → 客户档案"。
|
||
|
||
## 四、A3 财务页"账龄与对账单"标签(`FinanceReports.vue` 扩展)
|
||
|
||
- **应收账龄**:六桶柱状图(0-30 绿 / 61-180 橙 / 181+ 红)+ 明细表(金额、占比),支持 `as_of` 与单客户过滤,合计未结高亮。
|
||
- **客户对账单**:选客户 + 日期区间 → 期初/期末描述 + 逐行滚动余额明细(应收红、收款绿)→"生成分享链接"按钮(自动复制到剪贴板,7 天有效)。
|
||
- **分享记录表**:客户/区间/有效期/状态(有效/已吊销)+ 复制链接 / 预览(新标签打开客户侧页面)/ 吊销。
|
||
|
||
## 五、A4 + A5
|
||
|
||
**A4 批次效期(`Batches.vue`[新],路由 `/batches`)**
|
||
- 过滤:仓库 / 商品 / 只看有量 / **只看近效期(≤30 天)**;汇总"批次 N 个|在库合计 X"。
|
||
- 列表:批次号、生产/到期日、**距到期天数着色**(≤7 红、≤30 橙、>30 绿、已过期红并显示"已过期 N 天")、结存/可用/成本/货值。
|
||
- `Notify.vue`:通知类别**图标分类**(🏷️ 批次效期 / 💳 信用 / 📦 库存 / 💰 逾期 / ⚠️ 其他预警)。
|
||
- 打印:销售/进货默认模板**条件列 "批次号"**(`{{#if has_batch_lines}}`),销售单批次号来自出库流水的 FEFO 分摊明细(`batch_detail`),多批分摊以 `/` 连接。
|
||
|
||
**A5 采购开单(`PurchaseBills.vue` 重写)**
|
||
- 新建入口 + 行级单位/数量/单价;**批次信息区**(仅批次商品行出现):批次号(必填)/生产日期/到期日/保质期。
|
||
- 生产日期选后**按 `shelf_life_days` 自动推算到期日**并提示;批次号缺失时前端拦截(与后端校验一致)。
|
||
- 列表草稿行可"过账";金额合计实时。
|
||
|
||
## 六、顺带修掉的两个真 bug
|
||
|
||
1. **DRF 同路径双 action 互相覆盖**:`finance/statements/share/` 原先写了 `create_share`(POST) 与 `list_shares`(GET) 两个 `url_path="share"` 的 action——DRF 只保留后者注册,导致 **GET 列表永久 405**。修复:合并为单 action 按 `request.method` 分派(`_create_share` 提为私有方法)。
|
||
2. **`?format=` 与 DRF 内容协商冲突**:对账单公开开关最初用 `?format=html`,但 DRF 默认 `URL_FORMAT_OVERRIDE="format"`,找不到 html renderer 会直接 **404**。修复:改用 `?view=html`,并在 docstring 里写明这个坑。
|
||
|
||
## 七、验证证据
|
||
|
||
### 1. 后端回归(零破坏)
|
||
```
|
||
$ python -m pytest tests/ -p no:cacheprovider
|
||
160 passed in 3.50s # 原 143 + 新增 17(tests/test_batch_a_api.py)
|
||
$ python manage.py check
|
||
System check identified no issues (0 silenced).
|
||
```
|
||
|
||
### 2. `npm run build`
|
||
```
|
||
✓ built in 9.55s
|
||
dist/assets/Customers-Cge4xKIc.js 4.57 kB ← 新页面已进产物
|
||
dist/assets/Batches-BNgU4pW7.js 3.93 kB ← 新页面已进产物
|
||
```
|
||
|
||
### 3. Playwright 真实浏览器(Granian ASGI,127.0.0.1:8010)
|
||
|
||
| 场景 | 结果 |
|
||
|---|---|
|
||
| 登录 → 销售开单 → 选客户 | 信用额度条显示 `已用 ¥0 / 共 ¥800 / 可用 ¥800` |
|
||
| 选商品 P001 | 单价自动预填 2.00,来源标签"默认售价",金额联动 |
|
||
| 单位切"箱×24" | 单价 48.00、金额 ¥48.00(2×24 正确换算) |
|
||
| 单价改 48.66 + 抹元 | 底部实时"行合计 48.66 → 抹零 -0.66 → 应收 48.00" |
|
||
| 保存并过账 | 单号 XS202609100001,库存 500→476 |
|
||
| 信用超限(额度 800,下单 900) | HTTP **402** + `code=credit_limit_exceeded`,提示"已用 48 + 本单 900 > 额度 800";`force=true` → 200 过账 + 写超限预警 |
|
||
| 批次页 | B20260915 剩 14、距到期 **21 天**;勾"只看近效期"过滤正确 |
|
||
| 客户档案展开 | 李四超市:`已用 ¥948 / 总额度 ¥800 / 可用 ¥-148` + 100% 红条 + "已超限" + 账龄六桶合计 948 |
|
||
| 财务页账龄标签 | 六桶柱状图 + 明细表,合计未结 1723 |
|
||
| 对账单生成 | 期初 0 → 明细 2 行 → 期末 948,逐行滚动余额正确 |
|
||
| 分享链接 → 匿名访问 | 新标签打开**客户侧对账单页面**(卡片式期初/本期应收/收款/期末 + 明细表 + 打印按钮) |
|
||
| 吊销后访问 | HTTP **404** |
|
||
| 批次打印 | 销售单 HTML 含"批次号"列与 `B20260901/B20260915`(FEFO 跨批分摊) |
|
||
| 非批次打印 | 无"批次号"列(条件块正确) |
|
||
| 通知页扫描 | 🏷️ 近效期(B20260915 距到期 21 天)、💳 信用超限(李四超市,已放行)两条新预警 |
|
||
|
||
### 4. 关键接口手测(curl)
|
||
|
||
```
|
||
GET /api/v1/catalog/products/1/units/ → 瓶(×1, ¥2) / 箱(×24, ¥48) / 提(×6, ¥12)
|
||
POST /api/v1/sales/bills/create-bill/ → total 48.0000, round_off 0.6600, quantity 24, source_quantity 1
|
||
POST /api/v1/sales/bills/1/confirm/ → state=confirmed;库存 500→476
|
||
POST /api/v1/sales/bills/2/confirm/ → 402 credit_limit_exceeded → force → 200
|
||
GET /api/v1/finance/statements/receivable-aging/ → 0-30: 948.0000, total 948.0000
|
||
POST /api/v1/finance/statements/share/ → 201 + token
|
||
GET /api/v1/open/statements/<tok>/ → 200 text/html(对 账 单 / 李四超市 / 948.00 / window.print)
|
||
POST /api/v1/finance/statements/share/<tok>/revoke/ → 200;随后匿名访问 404
|
||
```
|
||
|
||
## 八、变更文件清单
|
||
|
||
```
|
||
backend/
|
||
├── apps/catalog/views.py [改] +products/<id>/units/ 动作接口
|
||
├── apps/catalog/serializers.py [改] 商品增单位/最低售价/批次字段
|
||
├── apps/sales/views.py [改] +create_bill / +confirm_bill(402/400 语义映射)
|
||
├── apps/sales/serializers.py [改] 行增单位字段、单增客户名/抹零额
|
||
├── apps/purchase/views.py [改] +create_bill / +confirm_bill
|
||
├── apps/purchase/serializers.py [改] 行增批次三字段/单位名/批次标记
|
||
├── apps/finance/views.py [改] share 合并按方法分派、+revoke、+view=html 输出
|
||
├── apps/finance/statement_html.py [新] 客户侧对账单 HTML 渲染(全字段转义)
|
||
├── apps/printing/renderer.py [改] +{{#if}}/{{else}}(支持嵌套)
|
||
├── apps/printing/services.py [改] 行上下文增批次号/FEFO 分摊、默认模板条件批次列
|
||
└── tests/test_batch_a_api.py [新] 17 例
|
||
|
||
frontend/src/
|
||
├── pages/SalesBills.vue [重写] 开单:取价/单位/抹零/最低价/信用
|
||
├── pages/PurchaseBills.vue [重写] 开单 + 批次信息区 + 自动推算到期日
|
||
├── pages/Customers.vue [新] 客户档案:额度进度条 + 账龄
|
||
├── pages/Batches.vue [新] 批次效期页(距到期着色 + 近效期过滤)
|
||
├── pages/FinanceReports.vue [改] +"账龄与对账单"标签(柱状图/对账单/分享管理)
|
||
├── pages/Notify.vue [改] 通知类别图标分类
|
||
├── router/index.js [改] +/customers /batches
|
||
└── layouts/MainLayout.vue [改] 侧栏 +客户档案 +批次效期
|
||
frontend/dist/ [重建]
|
||
```
|
||
|
||
## 九、遗留 / 下一步
|
||
|
||
- **A 批次范围内无遗留**:计划中 A1–A5 五个子项验收点全部覆盖(含 Playwright 冒烟)。
|
||
- **踩坑记录**(供后续批次参考):
|
||
1. adrf async 视图里 **不能直接触碰惰性 FK / `refresh_from_db()`**,必须整体塞进 `sync_to_async`(本轮 3 处线上 500 均源于此)。
|
||
2. DRF **同一 `url_path` 只能有一个 action**,同名同路径后者覆盖前者(表现为 405)。
|
||
3. `?format=` 是 DRF 保留参数(`URL_FORMAT_OVERRIDE`),自定义输出格式请换参数名。
|
||
4. Granian 在 Windows 上**不支持多 worker**(自动回落 1),且旧进程 socket 可能残留——
|
||
改动后重启务必 `netstat` 确认端口只有一个 PID,否则旧实例会抢答导致"改了没生效"。
|
||
- **下一步(按计划)**:批次 B(AI 应收风险预警 → AI 开单 → AI 经营问答),与批次 C(计费/演示账套/官网/上线)并行;建议先做 **B1 AI 应收风控**作为主打卖点。
|