# 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//units/` | 商品可用录入单位 + 换算率 + 折算售价(含基本单位),供开单页"选择录入单位" | `apps/catalog/views.py` | | `POST /sales/bills/create-bill/` | 按行解析单位/取价/抹零后建草稿单;余额不足返回 400 `insufficient_stock` | `apps/sales/views.py` | | `POST /sales/bills//confirm/` | 过账;**402 + `code=credit_limit_exceeded`** 可前端弹"强制过账",`force=true` 放行 | 同上 | | `POST /purchase/bills/create-bill/` | 建草稿(支持批次号/生产日/到期日/录入单位) | `apps/purchase/views.py` | | `POST /purchase/bills//confirm/` | 过账(写库存 + 批次账 + 应付) | 同上 | | `POST /finance/statements/share//revoke/` | 吊销对账单分享链接 | `apps/finance/views.py` | | `GET /open/statements//`(增强) | 浏览器/`?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// → 200 text/html(对 账 单 / 李四超市 / 948.00 / window.print) POST /api/v1/finance/statements/share//revoke/ → 200;随后匿名访问 404 ``` ## 八、变更文件清单 ``` backend/ ├── apps/catalog/views.py [改] +products//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 应收风控**作为主打卖点。