Files
dealerhub/PROGRESS_AGI_ITERATION_4.md

304 lines
12 KiB
Markdown
Raw Permalink 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 · AGI 自主迭代报告(第 4 轮 · 并发序列 + N+1 优化)
> **承接**:第 3 轮报告末尾提出的"`_generate_bill_no` 用 count()+1 取序号,并发下有竞态"。
> 本轮坐实并修复,同时扫描出**另外 4 处同类问题**;顺带做性能基线,
> 发现并修复列表接口的 **N+1**(5.3 倍提速)。
> **回归**:SQLite **454 passed** / PostgreSQL **458 passed**,`check` 0 issues。
---
## 一、单号生成竞态(PG 实测坐实)
### 症状
9 个并发建单 → **多个拿到同一单号**:
```
IntegrityError: 重复键违反唯一约束 "sales_bill_tenant_id_bill_no_uniq"
DETAIL: 键值"(tenant_id, bill_no)=(1, XS202609110001)" 已经存在
```
### 根因:`count() + 1` 有两个缺陷
```python
seq = Model.objects.filter(tenant=tenant, bill_no__startswith=head).count() + 1
```
1. **并发撞号**:两个事务同时 `count()` 拿到同一个数 → 生成相同单号
2. **删除后复用**(更隐蔽):建了 001/002 后删掉 001 → `count()=1` → 下一张又是 002
→ 撞唯一约束(因为 002 已存在)
### 修复:`MAX(序号)+1` + 唯一约束重试
新增共享工具(`apps/core/services.py`):
```python
next_bill_no(tenant, prefix, model, *, date_str=None, field="bill_no")
# 扫已有单号取 MAX(序号),而非 count() —— 删除后不回退
create_with_unique_bill_no(model, *, tenant, prefix, defaults, field="bill_no")
# 取号 → 尝试创建(保存点包裹)→ 撞唯一约束则换号重试,最多 20 次
```
**为什么用重试而不是锁**:`select_for_update()` **锁不住不存在的行**
(这是第 3 轮修库存竞态时学到的教训)。乐观重试更适合"创建时取名"场景。
**保存点必不可少**:`IntegrityError` 会把外层事务标记为 aborted,
后续任何查询都报 `TransactionManagementError`。
### 修复范围:5 处同类序列
| 位置 | 用途 | 修复方式 |
|---|---|---|
| `sales.services._generate_bill_no` | 销售单 XS | `create_with_unique_bill_no` |
| `purchase.services._generate_bill_no` | 进货单 PB | 同上 |
| `storefront.services._next_order_no` | 商城单 HD | 同上(`field="order_no"`) |
| `channel.services` | 电商转单 SO | `create_with_unique_bill_no` |
| `finance.services._generate_ar_ap_no` | 应收/应付 RC/PY | `next_bill_no` + 凭证号重试 |
| `finance.services` 凭证号 V | 记账凭证 | 保存点 + 换号重试 |
### 验证(3 个场景)
```python
# 1. 并发
test_concurrent_bill_no_generation_unique # 9 线程并发建单,单号必须全唯一
# 2. 删除后不复用
test_bill_no_not_reused_after_deletion # 建 001/002/003 → 删 001 → 新单必须是 004
# 3. 空洞不回退
test_bill_no_survives_gap # 人工造 0099 → 新单必须是 0100(不填空洞)
```
修复前 1 失败(撞唯一约束);修复后 3 个全过。
---
## 二、性能基线(发现 N+1)
### 背景
前几轮补了数据(22 商品 / 90 单据 / 200+ 库存流水),数据量上来后低效查询才暴露。
单测只验证"对不对",不验证"代价多大"。
### 新增工具:`scripts/perf_baseline.py`
对 12 个主要读接口各发 N 次请求,报告 P50/P95/最大耗时 + 响应体积,按接口设阈值:
```
接口 P50 P95 最大 响应 状态
sales_bill_list 236.2ms 265.7ms 278.3ms 42,757B 200 ⚠ 慢 ← 唯一超标
product_list 50.5ms 53.4ms 64.1ms 10,828B 200 ✓
risk_ranking 104.7ms 112.2ms 113.8ms 3,106B 200 ✓
dashboard_summary 14.3ms 14.7ms 14.7ms 257B 200 ✓
...
```
### N+1 定位
用 `CaptureQueriesContext` 数 SQL:
```
序列化 50 张销售单:168.0ms,SQL 查询数:311
105× SELECT catalog_product... ← 每行的商品
105× SELECT catalog_unit... ← 每行的单位
50× SELECT SUM(tax_amount)... ← 每张单的税额聚合(SerializerMethodField)
50× SELECT sales_bill_line... ← 每张单的明细
1× SELECT sales_bill... ← 主查询
```
**三个根因**:
1. `tax_total` 用 `obj.lines.aggregate(Sum(...))` —— 每张单一次聚合
2. `lines` 未预取
3. 行内 `product` / `source_unit` 未预取
### 修复
**1. 基类加预取声明机制**(`apps/core/viewset.py`):
```python
class BaseTenantViewSet:
select_related_fields: tuple = ()
prefetch_related_fields: tuple = ()
async def get_queryset(self):
...
if self.select_related_fields:
qs = qs.select_related(*self.select_related_fields)
if self.prefetch_related_fields:
qs = qs.prefetch_related(*self.prefetch_related_fields)
```
**2. 各 ViewSet 声明自己的关联**(销售单/销售订单/采购单/库存/批次/应收/应付)。
**3. `tax_total` 改为内存汇总**:
```python
def get_tax_total(self, obj):
return str(sum((ln.tax_amount or 0) for ln in obj.lines.all()))
# 旧实现 obj.lines.aggregate(Sum("tax_amount")) 每张单一条 SQL
```
### 效果
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| SQL 查询数(50 张单) | **311** | **5** | 62× |
| 序列化耗时 | 168ms | 31ms | 5.4× |
| 接口 P95 | 266ms ⚠ | **50ms** | **5.3×** |
其他接口连带改善:`stock_list` 41→13ms、`batch_list` 31→13ms。
### 守门测试:`tests/test_query_efficiency.py`(7 例)
用 SQL 计数把"查询次数"钉死,防止后人改回 N+1:
```python
def test_sales_bill_list_scales_flat(...):
"""单据翻倍(20 → 40)时查询次数不增加。"""
...
assert n40 <= n20 + 2, f"查询数随数据量增长:{n20} → {n40}(N+1 回归)"
```
**验证守门有效性**(关键步骤):
临时撤掉 `SalesBillViewSet` 的预取声明 → 测试立刻失败:
```
AssertionError: 查询数随数据量增长:124 → 244(N+1 回归)
```
还原后 7 例全过。**这一步不能省**——否则可能是个永远为真的摆设
(第 1 轮就踩过 pytest 版迁移检查假通过的坑)。
**插曲**:我第一次验证时改错了地方(删的是 `SalesOrderViewSet` 的声明,
而测试测的是 `SalesBillViewSet`),导致"测试没抓到"的假象。
教训:验证守门测试时,要确认改动的是**测试真正覆盖的那条路径**。
---
## 三、验证汇总
```
$ DJANGO_SETTINGS_MODULE=config.settings.test python -m pytest tests/
449 passed, 4 skipped in 54.20s
$ DJANGO_SETTINGS_MODULE=config.settings.pgtest python -m pytest tests/
453 passed in 94.53s
$ python manage.py check
System check identified no issues (0 silenced).
$ python scripts/perf_baseline.py
✓ 全部接口在阈值内
```
### 本轮新增测试(+11)
| 模块 | 用例 | 覆盖 |
|---|---|---|
| `test_concurrency.py` | +3 | 单号并发唯一 / 删除不复用 / 空洞不回退 |
| `test_query_efficiency.py` | +7 | 4 个列表接口的 SQL 计数 + 分页上限 + 数据正确性 |
| `scripts/perf_baseline.py` | — | 12 接口性能基线(输出 JSON 便于对比) |
---
## 四、变更文件
```
backend/
├── apps/core/services.py [改] +next_bill_no / create_with_unique_bill_no
├── apps/core/viewset.py [改] +select_related_fields / prefetch_related_fields
├── apps/sales/services.py [改] 建单走并发安全单号
├── apps/sales/serializers.py [改] tax_total 改内存汇总(消除 50 次聚合)
├── apps/sales/views.py [改] 声明预取
├── apps/purchase/services.py·views.py [改] 同上
├── apps/storefront/services.py [改] 商城单号并发安全
├── apps/channel/services.py [改] 电商转单号并发安全
├── apps/finance/services.py [改] 应收/应付/凭证号并发安全
├── apps/inventory/views.py [改] 声明预取
├── apps/finance/views.py [改] 声明预取
├── scripts/perf_baseline.py [新] 性能基线(12 接口 + 阈值)
├── perf_baseline.json [新] 基线结果(便于回归对比)
└── tests/ test_concurrency.py(+3) · test_query_efficiency.py(7)
```
---
## 五、四轮迭代累计
| 指标 | 迭代前 | R1 | R2 | R3 | **R4** |
|---|---|---|---|---|---|
| 测试用例 | 348 | 393 | 421 | 443 | **458** |
| 修复缺陷 | — | 10 | 13 | 16 | **20** |
| 数据库后端 | SQLite | SQLite | SQLite | +PG | PG |
| 演示数据 | 5 商品 | 5 | 5 | 22 商品 | 22 商品 |
| 列表接口 P95 | 未测 | 未测 | 未测 | 未测 | **50ms**(原 266ms) |
**累计修复的高危问题**:
1. 负数量出库(凭空造库存)
2. 负单价(倒贴出货)
3. 极小金额 / 0 金额单据
4. 6 个页面 500(缺列)
5. 迁移未生成导致风控失效
6. 前端校验被控件掩盖
7. 关键操作无审计
8. 并发首次入库竞态(PG 实测)
9. seed 幂等被共享 rng 破坏
10. **单号生成并发撞号 + 删除后复用(5 处)**
11. **列表接口 N+1(311 → 5 SQL)**
12. **采购侧漏接价格校验(0 成本污染加权平均成本)**
---
## 六、前端表单校验审计(逼出 1 个后端缺口)
方法(沿用第 2 轮的"填非法值看是否真被拦"):用 Playwright 逐页扫描 15 个页面,
对能输入的页面实际填入非法值,观察"是否标红 + 是否被拦 + 是否有提示"。
### 扫描结果
15 个页面全部**无渲染错误**(无 `请求失败` / `undefined` / `NaN`)。
销售开单页实测:
| 测试 | 标红 | 提交结果 |
|---|---|---|
| 数量填 0 | ✓ | 被拦,提示"有 1 行数量不合法(需 ≥0.001 且 ≤10 亿)" |
| 单价填 -5 | ✗ | **无提示、无标红**(控件静默把 -5 纠正了) |
### 发现的缺口
**1. 前端:单价校验缺失**
`isValidPrice` 允许 0,且提交前**没有校验价格**(只校验了数量)。
用户填 -5 后被 `el-input-number` 静默改回,没有任何反馈——用户不知道发生了什么。
**2. 后端:采购侧漏接价格校验(真缺口)**
顺着前端问题查后端,发现 `purchase.services._resolve_line` **没有调用 `parse_price`**:
```python
price = Decimal(str(ln["unit_price"])) # ← 直接转换,未校验
```
第 1 轮我给销售侧接了校验,**采购侧遗漏了**。0 元进货当时只能靠下游"金额 > 0"兜住
(错误码是模糊的 `invalid_line`),而且 **0 成本入库会污染加权平均成本**
(把真实成本拉低 → 毛利虚高)。
### 修复
- 前端:`isValidPrice` 改为 `> 0`;提交前增加价格校验;单价非法标红(`.price-invalid`)
- 后端:`parse_price` 从 `≥ 0` 收紧为 `> 0`;采购 `_resolve_line` 接入 `parse_price`
- 新增 5 个测试:0 价销售/采购被拒、负价被拒、正常价格不受影响、
**0 成本不污染加权平均成本**(单元级)
---
## 七、下一轮候选
| 优先级 | 项目 | 说明 |
|---|---|---|
| P0 | 前端表单校验审计 | 用第 2 轮方法(填非法值看是否真被拦)逐页验证,只做过销售/采购 |
| P1 | 生产切 PG | 本机已完全跑通(含并发),服务器 192.168.5.7 也应切;SQLite 不适合生产 |
| P1 | 索引审查 | PG 上开 `EXPLAIN ANALYZE` 看大表是否有全表扫描(如 bill_date 范围查询) |
| P1 | 审计日志保留策略 | 当前无限增长(demo 已有 267 条,生产会更快) |
| P2 | 并发场景扩展 | 额度并发占用(两个订单同时用尽额度)、库存 + 单据混合并发 |
| P2 | 缓存层 | `risk_ranking` 110ms(遍历客户算分),可加短 TTL 缓存 |