Files
shanxia2.0/docs/superpowers/plans/2026-10-15-pl-segment-export-extend.md
T
2026-10-19 18:28:02 +08:00

289 lines
12 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.
# 运输计划相关列表导出扩展 · 段次×产品行 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 危化品证、运输归档、合同完善导出对齐列表段次×产品行(方案 A);出库单已完成仅验收。
**Architecture:** 复用基线 `_iter_segment_product_slots` / `_format_stop_segment_label`;归档改造 `build_plan_archive_export_rows` 为段次多行 + `planQty`/`actualQty`/`unitPrice`/`amount`;合同完善 `export_contract_gaps` 对缺口计划做段次展开;证件仅前端注入 `stopSegment`。
**Tech Stack:** Python/FastAPI、pytest、Vue 3
**Spec:** `docs/superpowers/specs/2026-10-15-pl-segment-export-extend-design.md`
**Do NOT git commit unless user explicitly asks.**
---
## File map
| 文件 | 职责 |
|------|------|
| `web-admin/src/views/pl/plan-cert/index.vue` | 导出列注入 `stopSegment` |
| `backend/app/api/pl/plan.py` | `PLAN_ARCHIVE_EXPORT_COLUMNS`、`ARCHIVE_PRICE_EXPORT_FIELDS`、`CONTRACT_GAP_EXPORT_COLUMNS`、`export_contract_gaps` |
| `backend/app/services/pl_plan_archive_export.py` | 段次展开替换配对宽表默认路径 |
| `backend/tests/test_pl_plan_archive_export.py` | 归档段次导出用例 |
| `backend/tests/test_pl_plan_contract_gap_export.py`(新建或扩现有) | 合同完善段次导出用例 |
| `web-admin/src/views/pl/plan-archive/index.vue` | 导出可见列映射对齐新 field(`stopSegment`、去掉旧宽列 prop) |
| `web-admin/src/views/pl/plan-contract/index.vue` | `contractGapExportColumns` 对齐新列 |
---
### Task 1: 危化品证上传 — 注入 stopSegment
**Files:**
- Modify: `web-admin/src/views/pl/plan-cert/index.vue`
- [ ] **Step 1: 改 `planCertExportColumns`**
对照运输计划列表写法,在 `planNo` 后插入 `stopSegment`:
```typescript
const planCertExportColumns = computed(() => {
const skip = new Set([
'loadCustomer',
'deliveryCustomer',
'loadAddressTime',
'deliveryAddressTime'
])
const cols = visibleColumns.value
.filter((col) => !skip.has(col))
.map((col) => PLAN_CERT_EXPORT_COLUMN_FIELD_MAP[col] || col)
if (!cols.includes('planNo')) cols.unshift('planNo')
const planNoIdx = cols.indexOf('planNo')
if (!cols.includes('stopSegment')) {
cols.splice(planNoIdx + 1, 0, 'stopSegment')
}
return cols
})
```
- [ ] **Step 2: 自检** — 读文件确认 splice 存在;无需后端改动。
---
### Task 2: 归档导出 — 失败测试(段次×产品)
**Files:**
- Modify: `backend/tests/test_pl_plan_archive_export.py`
- [ ] **Step 1: 新增/改写主用例为段次多行**
将 `test_build_plan_archive_export_rows_pair_mode_and_single_side` 的「默认 stop_kind=None → 1 行配对」改为期望:
```python
# stop_kind=None → 段次展开:装货 1 行 + 送货 1 行 = 2 行
rows = build_plan_archive_export_rows(..., stop_kind=None)
assert len(rows) == 2
assert [r["stopSegment"] for r in rows] == ["装货", "送货 #2"]
assert rows[0]["loadCustomer"] == internal
assert rows[0]["deliveryCustomer"] == ""
assert rows[0]["planQty"] == ... # load product plan_qty
assert rows[0]["actualQty"] == Decimal("10") # inbound
assert rows[0]["unitPrice"] == Decimal("100")
assert rows[1]["deliveryCustomer"] == "外部客户"
assert rows[1]["loadCustomer"] == ""
assert rows[1]["actualQty"] == Decimal("9")
# 不得再有 loadPlanQty + deliveryPlanQty 同排有值的配对语义
assert "loadPlanQty" not in rows[0] or rows[0].get("loadPlanQty") is None # 更好:列定义已无此 field,行 dict 可不含
```
`stop_kind="load"` / `"delivery"` 仍只输出对应侧行(可用 `stopSegment` 断言,兼容旧 `stopKind` 可删)。
- [ ] **Step 2: 改写依赖配对的测试**
- `test_append_paired_product_rows_*`:若配对函数保留仅供内部/删除,则删除或改为测新 append helper。
- 品牌/别名用例改为走 `build_plan_archive_export_rows` 段次路径断言。
- [ ] **Step 3: 跑测确认 RED**
```bash
cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_archive_export.py -v --tb=line
```
---
### Task 3: 归档导出 — 实现段次列与行构建
**Files:**
- Modify: `backend/app/api/pl/plan.py`(`PLAN_ARCHIVE_EXPORT_COLUMNS`、`ARCHIVE_PRICE_EXPORT_FIELDS`)
- Modify: `backend/app/services/pl_plan_archive_export.py`
- [ ] **Step 1: 替换 `PLAN_ARCHIVE_EXPORT_COLUMNS`**
```python
PLAN_ARCHIVE_EXPORT_COLUMNS = [
ExportColumn(title="序号", field="rowNo"),
ExportColumn(title="计划编号", field="planNo"),
ExportColumn(title="段次", field="stopSegment"),
ExportColumn(title="合同编号", field="contractNo"),
ExportColumn(title="计划日期", field="planDate"),
ExportColumn(title="归档状态", field="archiveStatus"),
ExportColumn(title="跟进状态", field="followStatus"),
ExportColumn(title="单据类型", field="planType"),
ExportColumn(title="装货客户", field="loadCustomer"),
ExportColumn(title="装货地址", field="loadAddress"),
ExportColumn(title="装货时间", field="loadTime"),
ExportColumn(title="送货客户", field="deliveryCustomer"),
ExportColumn(title="送货地址", field="deliveryAddress"),
ExportColumn(title="送货时间", field="deliveryTime"),
ExportColumn(title="产品名称", field="productName"),
ExportColumn(title="品牌", field="brandName"),
ExportColumn(title="别名", field="aliasName"),
ExportColumn(title="型号/规格", field="model"),
ExportColumn(title="包装", field="packageSpec"),
ExportColumn(title="单位", field="unit", dict_type="crm_product_unit"),
ExportColumn(title="计划量", field="planQty"),
ExportColumn(title="实际量", field="actualQty"),
ExportColumn(title="单价(元)", field="unitPrice"),
ExportColumn(title="金额(元)", field="amount"),
ExportColumn(title="车牌号", field="licensePlate"),
ExportColumn(title="运费单位", field="freightType"),
ExportColumn(title="运费单价", field="freightPrice"),
ExportColumn(title="运费金额", field="freightAmount"),
ExportColumn(title="ERP", field="erpEntered"),
ExportColumn(title="备注", field="remark"),
]
```
`ARCHIVE_PRICE_EXPORT_FIELDS` 保留 `unitPrice`/`amount`/`freightPrice`/`freightAmount`;可移除已删除的 `loadUnitPrice` 等(过滤仍安全)。
- [ ] **Step 2: 重写 `build_plan_archive_export_rows` 默认路径**
伪逻辑:
```python
from app.api.pl.plan import _iter_segment_product_slots, _format_stop_segment_label
# 避免循环 import:把 _iter_segment_product_slots / _format_stop_segment_label
# 抽到 app/services/pl/plan_list_export_segments.py 更干净(推荐本 Task 内抽取)
```
**推荐抽取**(若 `plan.py` ↔ `pl_plan_archive_export.py` 循环导入):
- Create: `backend/app/services/pl/plan_list_export_segments.py`
- `iter_segment_product_slots`
- `format_stop_segment_label`
- Update `plan.py` 改为从该模块 import(保持 `_iter_segment_product_slots` 别名或直接替换调用)
- Archive/contract 从该模块 import
对每个 plan:
1. `plan_common = _plan_common_export_fields(...)`
2. 取 stops;legacy 无段 → 旧 `_append_legacy_plan_product_rows` 但补 `stopSegment=""`、`planQty`/`actualQty`
3. 有段:`slots = iter_segment_product_slots(load, deliveries)`;若 `stop_kind=="load"` 只保留 load;`=="delivery"` 只保留 delivery
4. 每槽位一行:
- `stopSegment = format_stop_segment_label(kind, seq)`
- 装货行:填 loadCustomer/Address/Time,delivery 侧空;送货行相反
- `contractNo` = 本段合同 label(`format_stop_contract_export_label`)
- 产品 identity + `planQty`/`actualQty`/`unitPrice`/`amount` 用现有 `_get_stop_archive_*`
5. `stop_customer_keywords`:保留——装或送客户匹配即保留该行(按行侧客户过滤)
可删除或停用 `_append_paired_product_rows`(若测试不再引用)。
- [ ] **Step 3: 测试全绿**
```bash
cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_archive_export.py tests/test_pl_plan_list_export.py -v --tb=line
```
- [ ] **Step 4: 前端归档导出列**
`plan-archive/index.vue`:导出 columns 映射增加 `stopSegment`;若前端曾请求 `loadPlanQty` 等,改为 `planQty`/`actualQty`/`unitPrice`/`amount`(与列表 prop 一致)。找到构建 export columns 的 computed,对齐新 `PLAN_ARCHIVE_EXPORT_COLUMNS`。
---
### Task 4: 合同完善导出 — 测试 + 实现
**Files:**
- Create/Modify: `backend/tests/test_pl_plan_contract_gap_export.py`(或扩现有相关测)
- Modify: `backend/app/api/pl/plan.py`(`CONTRACT_GAP_EXPORT_COLUMNS`、`export_contract_gaps`)
- Modify: `web-admin/src/views/pl/plan-contract/index.vue`
- [ ] **Step 1: 写失败测试**
对 `export_contract_gaps` 的行构建逻辑抽函数更易测,例如:
```python
def build_contract_gap_export_rows(db, tenant_no, gap_items, stops_by_plan) -> list[dict]:
...
```
测试:一计划,装货缺合同 + 两送货段有产品 → 多行;装货行 `gapType=="缺供方合同"`(或与现文案一致的短标签);送货行若无缺口则 `gapType==""`;`stopSegment` 正确。
缺口判定复用 `stop_contract_gap(customer_name, len(contracts))`(`pl_plan_contract_gap.py`)。
- [ ] **Step 2: 更新列定义**
```python
CONTRACT_GAP_EXPORT_COLUMNS = [
ExportColumn(title="计划编号", field="planNo"),
ExportColumn(title="段次", field="stopSegment"),
ExportColumn(title="计划日期", field="planDate"),
ExportColumn(title="送货时间", field="deliveryTimes"),
ExportColumn(title="单据类型", field="planType"),
ExportColumn(title="跟进状态", field="followStatus"),
ExportColumn(title="装货客户", field="loadCustomer"),
ExportColumn(title="送货客户", field="deliveryCustomer"),
ExportColumn(title="产品名称", field="productName"),
ExportColumn(title="供方", field="supplierName"),
ExportColumn(title="需方", field="customerName"),
ExportColumn(title="缺口类型", field="gapType"),
]
```
- [ ] **Step 3: 实现 `export_contract_gaps`**
1. 仍 `list_plan_contract_gaps(...)` 得 items(含 plan id)
2. `load_plan_stops_grouped` for those ids
3. 对每个 gap item / plan:展开 slots;填计划级字段 + 段字段 + 产品名 + `gapType`
4. followStatus/planType 中文标签保持现有
- [ ] **Step 4: 前端 `contractGapExportColumns`**
```typescript
const contractGapExportColumns = [
'planNo',
'stopSegment',
'planDate',
'deliveryTimes',
'planType',
'followStatus',
'loadCustomer',
'deliveryCustomer',
'productName',
'supplierName',
'customerName',
'gapType'
]
```
- [ ] **Step 5: 跑测**
```bash
cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_contract_gap_export.py tests/test_pl_plan_list_export.py tests/test_pl_plan_archive_export.py -v --tb=line
```
---
### Task 5: 收尾
- [ ] **Step 1: 全量相关测**
```bash
cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_list_export.py tests/test_pl_plan_archive_export.py tests/test_pl_plan_contract_gap_export.py -v
```
- [ ] **Step 2: Spec 对照** E1–E6 均有落点;更新 graphify(改过的 backend/web-admin)
- [ ] **Step 3: 验收清单(人工)**
- 证件 / 归档 / 合同完善 / 出库单各导出一次一装多送计划
---
## Self-review
1. Spec E1–E6 均有 Task。
2. 循环 import 风险已用「抽取 `plan_list_export_segments.py`」处理。
3. 价格字段过滤与 zip 同源行模型已写明。
4. Commit 步骤省略(用户未要求不提交)。