diff --git a/docs/superpowers/plans/2026-09-05-pl-stop-stable-id-reuse-links.md b/docs/superpowers/plans/2026-09-05-pl-stop-stable-id-reuse-links.md new file mode 100644 index 000000000..7f79ae16a --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-pl-stop-stable-id-reuse-links.md @@ -0,0 +1,509 @@ +# 运输计划段次稳定 ID + 外围单据就地复用 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:** 保存/调整运输时保留装送货段与计划产品行主键,地磅关联就地更新、司机/现场 token 不轮换,避免反复编辑后详情出现「暂无关联地磅单」。 + +**Architecture:** 将 `replace_plan_stops` 与计划产品更新改为按 payload `id` upsert;调整运输改为按段 `upsert_stop_dibang_links`(不再整批 revoke 再建);默认不 rotate driver/site token。历史悬空 link 用运维脚本改挂(含 `PO20260904024`)。 + +**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2 + pytest;Vue 3 + TypeScript + +**Spec:** `docs/superpowers/specs/2026-09-05-pl-stop-stable-id-reuse-links-design.md` + +**注意:** 工作区可能有无关未提交改动。只改本计划列出的文件,提交时勿夹带无关 diff。 + +--- + +## 文件结构 + +| 操作 | 文件 | 职责 | +|------|------|------| +| Modify | `docs/superpowers/specs/2026-09-05-pl-stop-stable-id-reuse-links-design.md` | 状态改为已确认 | +| Modify | `backend/app/schemas/pl/plan.py` | `PlanLoadBody` / `PlanDeliveryBody` / `PlanProductCreate` 可选 `id` | +| Modify | `backend/app/services/pl_plan_stop_service.py` | 送货段/段产品 upsert,校验 id 归属 | +| Modify | `backend/app/services/pl_plan_service.py` | `build_plan_product` 支持沿用 id;产品更新 upsert | +| Modify | `backend/app/api/pl/plan.py` | update 产品路径改 upsert;已安排保存后调用地磅 upsert | +| Modify | `backend/app/services/pl_dibang_bridge_service.py` | `upsert_stop_dibang_links`;取消勾选阻断/软删 | +| Modify | `backend/app/api/pl/transport.py` | rearrange 不 rotate token;调用 upsert 替代 revoke+全量新建 | +| Modify | `web-admin/src/utils/pl-plan-stop-form.ts` | 表单保留/回传段 `id` | +| Modify | `web-admin/src/components/business/pl-plan-form-dialog/index.vue` | 计划产品 payload 回传 `id`(若此处组装) | +| Create | `backend/tests/test_pl_plan_stop_upsert.py` | 段 upsert 单测 | +| Create | `backend/tests/test_pl_dibang_upsert_on_arrange.py` | 地磅 upsert / 过磅阻断取消勾选 | +| Create | `backend/scripts/fix_pl_transport_db_link_stop_ids.py` | 历史 link 改挂 | +| Create | `docs/sql/migrations/2026-09-05-fix-pl-db-link-orphaned-stop.sql` | 可选手工 SQL(含案例) | + +--- + +### Task 1: Schema — Body 可选 id + +**Files:** +- Modify: `backend/app/schemas/pl/plan.py` +- Modify: `docs/superpowers/specs/2026-09-05-pl-stop-stable-id-reuse-links-design.md`(状态 → 已确认) + +- [ ] **Step 1: 为写入 Body 增加可选 id** + +在 `PlanProductCreate` 增加: + +```python +id: Optional[str] = Field(default=None, description="已有计划产品行ID;编辑回传以保留主键") +``` + +在 `PlanLoadBody` / `PlanDeliveryBody` 顶部增加: + +```python +id: Optional[str] = Field(default=None, description="已有段ID;编辑回传以保留主键") +``` + +- [ ] **Step 2: 更新设计文档状态** + +将 spec 头部 `状态:待审阅…` 改为 `状态:已确认`。 + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/schemas/pl/plan.py docs/superpowers/specs/2026-09-05-pl-stop-stable-id-reuse-links-design.md +git commit -m "$(cat <<'EOF' +feat(pl): allow optional stop/product ids on plan write bodies. + +EOF +)" +``` + +--- + +### Task 2: 送货段 upsert(核心) + +**Files:** +- Create: `backend/tests/test_pl_plan_stop_upsert.py` +- Modify: `backend/app/services/pl_plan_stop_service.py` + +- [ ] **Step 1: 写失败单测(期望 upsert 保留 delivery id)** + +```python +"""replace_plan_stops 应按 payload.id upsert 送货段,而非每次换新 UUID。""" +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from app.services.pl_plan_stop_service import resolve_delivery_id_for_upsert + + +def test_upsert_delivery_rejects_foreign_plan_id(): + with pytest.raises(HTTPException) as ei: + resolve_delivery_id_for_upsert( + plan_id="plan-1", + payload_id="del-other-plan", + existing_by_id={}, + ) + assert ei.value.status_code == 422 + + +def test_upsert_delivery_keeps_payload_id(): + existing = MagicMock() + existing.id = "del-keep" + existing.plan_id = "plan-1" + new_id, row = resolve_delivery_id_for_upsert( + plan_id="plan-1", + payload_id="del-keep", + existing_by_id={"del-keep": existing}, + ) + assert new_id == "del-keep" + assert row is existing + + +def test_upsert_delivery_generates_id_when_missing(monkeypatch): + from app.services import pl_plan_stop_service as svc + + monkeypatch.setattr(svc, "generate_uuid", lambda: "new-del") + new_id, row = resolve_delivery_id_for_upsert( + plan_id="plan-1", + payload_id=None, + existing_by_id={}, + ) + assert new_id == "new-del" + assert row is None +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +cd backend && source venv/bin/activate +pytest tests/test_pl_plan_stop_upsert.py -v +``` + +Expected: FAIL(`resolve_delivery_id_for_upsert` 未定义) + +- [ ] **Step 3: 实现 upsert 辅助函数并改造 `replace_plan_stops`** + +在 `pl_plan_stop_service.py`: + +1. **不要再**对全部 delivery `del_flag=1` 后无脑 `generate_uuid()`。 +2. 算法: + - 查询本计划全部 delivery(含 `del_flag=1`,用于复活)。 + - `kept_ids = set()` + - 对每个 `PlanDeliveryBody`: + - 若 `body.id`:必须在本计划 map 中,否则 422;取该行 UPDATE 字段并 `del_flag=0`;`delivery_id = body.id` + - 若无 `body.id`:`delivery_id = generate_uuid()` INSERT + - `kept_ids.add(delivery_id)` + - 段产品:对该 delivery 旧产品软删后按行写入;`plan_product_id` 继续走 `_resolve_plan_product_id` + - 未在 `kept_ids` 的旧有效 delivery → `del_flag=1`(及其段产品) +3. 装货段:保持现有 `_resolve_or_create_plan_load`。 +4. 合同行:可继续按现逻辑重写;本期硬目标是段 id 稳定。 + +```python +from typing import Optional + +def resolve_delivery_id_for_upsert( + *, + plan_id: str, + payload_id: Optional[str], + existing_by_id: dict, +) -> tuple[str, Optional[object]]: + pid = (payload_id or "").strip() + if not pid: + return generate_uuid(), None + row = existing_by_id.get(pid) + if row is None or getattr(row, "plan_id", None) != plan_id: + raise HTTPException(status_code=422, detail=f"送货段 id 无效或不属于本计划:{pid}") + return pid, row +``` + +将 `replace_plan_stops` 中送货循环的 `delivery_id = generate_uuid()` 替换为上述解析 + UPDATE/INSERT。 + +- [ ] **Step 4: 跑通单测** + +```bash +pytest tests/test_pl_plan_stop_upsert.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/pl_plan_stop_service.py backend/tests/test_pl_plan_stop_upsert.py +git commit -m "$(cat <<'EOF' +feat(pl): upsert plan deliveries by stable id on save. + +EOF +)" +``` + +--- + +### Task 3: 计划产品行 upsert + +**Files:** +- Modify: `backend/app/services/pl_plan_service.py`(`build_plan_product`) +- Modify: `backend/app/api/pl/plan.py`(update 产品块 ~1647–1706) +- Create/Extend: `backend/tests/test_pl_plan_product_upsert.py` + +- [ ] **Step 1: `build_plan_product` 支持传入 id** + +```python +def build_plan_product(..., product_row_id: Optional[str] = None) -> PlPlanProduct: + return PlPlanProduct( + id=(product_row_id or "").strip() or generate_uuid(), + ... + ) +``` + +- [ ] **Step 2: update_plan 产品路径改为 upsert** + +替换「全体 `del_flag=1` 再全新建」: + +```python +existing_all = db.query(PlPlanProduct).filter( + PlPlanProduct.plan_id == id, + PlPlanProduct.tenant_no == tenant_no, +).all() +existing_all_by_id = {row.id: row for row in existing_all} +kept: list[PlPlanProduct] = [] +kept_ids: set[str] = set() +for idx, prod in enumerate(payload.products): + row_id = (getattr(prod, "id", None) or "").strip() + if row_id and row_id in existing_all_by_id and existing_all_by_id[row_id].plan_id == id: + row = existing_all_by_id[row_id] + # 更新名称/数量/证书等字段,del_flag=0 + kept.append(row) + kept_ids.add(row_id) + else: + pp = build_plan_product(..., product_row_id=None) + db.add(pp) + kept.append(pp) + kept_ids.add(pp.id) +for row in existing_all: + if row.del_flag == 0 and row.id not in kept_ids: + row.del_flag = 1 +plan_products = kept +``` + +create-full 新建仍可不传 id。 + +- [ ] **Step 3: 单测 — 带 id 时主键不变** + +断言:`build_plan_product(..., product_row_id="pp-1").id == "pp-1"`;无 id 时走 `generate_uuid`。 + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/pl_plan_service.py backend/app/api/pl/plan.py backend/tests/test_pl_plan_product_upsert.py +git commit -m "$(cat <<'EOF' +feat(pl): upsert plan product rows by id on update. + +EOF +)" +``` + +--- + +### Task 4: 前端回传段 / 产品 id + +**Files:** +- Modify: `web-admin/src/utils/pl-plan-stop-form.ts` +- Modify: `web-admin/src/components/business/pl-plan-form-dialog/index.vue`(产品 payload) +- Modify: `web-admin/src/api/pl/index.ts`(若类型缺 `products[].id`) + +- [ ] **Step 1: 表单类型与 map/build** + +`PlanLoadForm` / `PlanDeliveryForm` 增加 `id?: string`。 + +`mapLoadFromApi` / `mapDeliveriesFromApi`: + +```typescript +id: load.id || undefined +// deliveries: +id: row.id || undefined +``` + +`createEmptyLoadForm` / `createEmptyDeliveryForm`:不设 id。 + +`buildLoadPayload` / `buildDeliveriesPayload`: + +```typescript +...(load.id ? { id: load.id } : {}), +// delivery: +...(row.id ? { id: row.id } : {}), +``` + +- [ ] **Step 2: 计划产品提交带 id** + +在 `buildPlanCommonPayload` / 最终 `products` 数组中,对已有行带上 `id: row.id`。确认类型允许 `products[].id`。 + +- [ ] **Step 3: 手工确认** + +编辑已有计划 → PUT body 的 `load.id`、`deliveries[].id`、`products[].id` 非空;新增送货段无 `id`。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/utils/pl-plan-stop-form.ts web-admin/src/components/business/pl-plan-form-dialog/index.vue web-admin/src/api/pl/index.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): submit stable load/delivery/product ids on plan save. + +EOF +)" +``` + +--- + +### Task 5: 地磅按段 upsert(替代整批 revoke 再建) + +**Files:** +- Create: `backend/tests/test_pl_dibang_upsert_on_arrange.py` +- Modify: `backend/app/services/pl_dibang_bridge_service.py` +- Modify: `backend/app/api/pl/transport.py` +- Modify: `backend/app/api/pl/plan.py`(已安排 PUT 后调用) + +- [ ] **Step 1: 写单测骨架** + +```python +def test_cancel_generate_dibang_blocked_when_weighing_exists(): + # mock link + inventory + _inventory_has_weighing_slip True + # generate_dibang=0 → ValueError 含批次号 + +def test_upsert_reuses_existing_inventory_id(): + # 已有 link(stop_id=del-1, db_inventory_id=inv-1) + # upsert 同 stop → 返回同一 inv-1,不新建 +``` + +- [ ] **Step 2: 实现 `upsert_stop_dibang_links`** + +相对现 `sync_dibang_for_plan_stops` + `_sync_stop_inventory_links`: + +1. 查本 plan/transport 有效 links。 +2. 勾选段+产品:已有 `(stop_type, stop_id, plan_product_id)` → 未过磅则更新可变字段;过磅则跳过并 `warnings`。无则新建 inventory + link。 +3. 取消勾选:有有效过磅 → `raise ValueError`;否则软删 link + 出入库并回减月计划贡献。 +4. link 存在且段 `product_id` 与出入库不一致 → `raise ValueError`(禁止静默换产品)。 + +可扩展 `sync_linked_dibang_dates_for_plan` 或并列字段同步:`partner_company` / `vehicle_no` / `quantity`(未过磅可写)。 + +- [ ] **Step 3: 改 `transport.py` 安排路径** + +- 去掉 rearrange 前整批 `revoke_regeneratable_dibang_for_transport`(取消勾选改由 upsert 按条处理)。 +- `sync_dibang_for_plan_stops` 改为 upsert;首次安排无 link = 新建。 + +- [ ] **Step 4: 已安排计划 PUT** + +在 `update_plan` 于 `replace_plan_stops` + 日期同步之后,若已安排则调用同一 upsert / 取消勾选校验(spec §4.2)。 + +- [ ] **Step 5: 跑测** + +```bash +pytest tests/test_pl_dibang_upsert_on_arrange.py tests/test_pl_dibang_date_sync_on_plan_save.py -v +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/pl_dibang_bridge_service.py backend/app/api/pl/transport.py backend/app/api/pl/plan.py backend/tests/test_pl_dibang_upsert_on_arrange.py +git commit -m "$(cat <<'EOF' +feat(pl): upsert dibang links in place instead of revoke-and-recreate. + +EOF +)" +``` + +--- + +### Task 6: 调整运输默认不 rotate token + +**Files:** +- Modify: `backend/app/api/pl/transport.py` +- Extend: 相关 tests + +- [ ] **Step 1: 改 rearrange 分支** + +将 rotate 改为 ensure(仅缺失时生成): + +```python +if is_rearrange: + _ensure_driver_access_token(transport) + if needs_site_link and plan_has_load_stop(db, tenant_no, plan.id): + ensure_transport_site_token(transport) + elif not needs_site_link: + transport.site_access_token = None + # 既有 void_io_orders 逻辑按需保留 +else: + _ensure_driver_access_token(transport) +``` + +保留 `_rotate_*` 供未来「作废并重发」,本期不调用。 + +- [ ] **Step 2: 单测 — rearrange 后 token 不变** + +已有 token 的 transport 走 rearrange 后 `driver_access_token` / `site_access_token` 相等。 + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/pl/transport.py backend/tests/ +git commit -m "$(cat <<'EOF' +fix(pl): keep driver/site tokens stable on rearrange. + +EOF +)" +``` + +--- + +### Task 7: 历史悬空 link 修复(含 PO20260904024) + +**Files:** +- Create: `backend/scripts/fix_pl_transport_db_link_stop_ids.py` +- Create: `docs/sql/migrations/2026-09-05-fix-pl-db-link-orphaned-stop.sql` + +- [ ] **Step 1: 脚本逻辑** + +对每个有效 `pl_transport_db_link`: + +1. `stop_type=delivery` 且 stop 已软删/不存在 → 同计划有效 delivery:优先同 `customer_name`;仅 1 条则唯一匹配。 +2. `plan_product_id` 已软删 → 同计划有效产品按相同 CRM `product_id` 改挂。 +3. 默认 dry-run;`--apply` 才写库;支持 `--plan-no PO20260904024`。 + +期望案例输出: + +``` +stop_id 1D4CB037… → 2036E1B2… +plan_product_id DA6B3548… → 537B42A6… +``` + +- [ ] **Step 2: SQL 备忘** + +```sql +UPDATE pl_transport_db_link +SET stop_id = '2036E1B2CEC54509ABC2B22DAA5786F3', + plan_product_id = '537B42A6B7E54D7CBD4C90DC73B185E7', + update_time = NOW() +WHERE id = '4AAE759627054CAAB6B5F36D4888CBA4' + AND plan_id = '551307353C99489786756A3F26C49020' + AND del_flag = 0; +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/scripts/fix_pl_transport_db_link_stop_ids.py docs/sql/migrations/2026-09-05-fix-pl-db-link-orphaned-stop.sql +git commit -m "$(cat <<'EOF' +chore(pl): add script to remount orphaned transport dibang stop links. + +EOF +)" +``` + +- [ ] **Step 4: 生产修复(人工确认后执行,不自动跑)** + +```bash +cd backend && source venv/bin/activate +python scripts/fix_pl_transport_db_link_stop_ids.py --plan-no PO20260904024 +python scripts/fix_pl_transport_db_link_stop_ids.py --plan-no PO20260904024 --apply +``` + +--- + +### Task 8: 回归清单与 graphify + +- [ ] **Step 1: 自动化回归** + +```bash +cd backend && source venv/bin/activate +pytest tests/test_pl_plan_stop_upsert.py tests/test_pl_plan_product_upsert.py tests/test_pl_dibang_upsert_on_arrange.py tests/test_pl_dibang_date_sync_on_plan_save.py tests/test_pl_plan_stop_service.py -v +``` + +Expected: PASS + +- [ ] **Step 2: 手工验收(spec §8)** + +1. 安排 → 改地址保存/调整 → 段 id、出入库 id、批次号不变;详情有地磅。 +2. 过磅后改地址 → 计划可存;详情仍有地磅。 +3. 过磅后取消地磅勾选 → 失败提示。 +4. 新增多段勾地磅 → 只新建新段单据。 +5. 调整运输 → 司机/现场旧链接仍可用。 +6. 复制计划 → 无源单 link。 +7. 修复后 `PO20260904024` 送货段可见磅单。 + +- [ ] **Step 3: 更新知识图谱** + +```bash +./scripts/update-graph.sh backend +./scripts/update-graph.sh frontend +``` + +--- + +## Spec 覆盖自检 + +| Spec 项 | Task | +|---------|------| +| G1 段/产品主键稳定 | Task 2–4 | +| G2 地磅就地更新 | Task 5 | +| G3 token 不 rotate | Task 6 | +| G4 详情可见地磅 | Task 2+4+7 | +| G5 历史修复 | Task 7 | +| D4 过磅取消勾选阻断 | Task 5 | +| D6 复制不带走 | Task 8 手工确认 | + +无 TBD/「稍后实现」占位。