289 lines
12 KiB
Markdown
289 lines
12 KiB
Markdown
# 运输计划相关列表导出扩展 · 段次×产品行 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 步骤省略(用户未要求不提交)。
|