# 运输计划归档 — 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:** 新增「运输计划归档」菜单与归档/调整弹窗,支持填写 actualQty、调整运费并归档;已归档计划仍可编辑与调整;原运输计划页移除归档入口。 **Architecture:** 后端在 `archive_plan` 单事务内编排产品数量同步 → 运费更新(含结算阻断)→ 首次归档改状态 / 已归档仅调数据;前端新建 `plan-archive` 列表 + `PlPlanArchiveDialog`(布局对齐 `PlPlanArrangeDialog`),列表逻辑抽取 `usePlPlanList` 与运输计划共用。 **Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + `usePlFreightCalc` **Spec:** `docs/superpowers/specs/2026-06-25-pl-plan-archive-design.md` --- ## 文件结构 | 文件 | 职责 | |------|------| | `backend/app/schemas/pl/plan.py` | `PlanArchiveBody` 等请求 Schema | | `backend/app/services/pl_plan_service.py` | `apply_archive_product_quantities`、`apply_archive_freight`、`transport_has_locked_freight_settlement`、`record_plan_change` | | `backend/app/api/pl/plan.py` | 增强 `archive_plan`;`update_plan` 允许已归档编辑 | | `web-admin/src/api/pl/index.ts` | `PlanArchiveBody` 类型 + `planApi.archive(id, body?)` | | `web-admin/src/composables/usePlPlanList.ts` | 列表查询/分页/筛选共享逻辑 | | `web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue` | 新增 `editableActualQtyOnly` | | `web-admin/src/components/business/pl-plan-archive-dialog/index.vue` | 归档/调整弹窗 | | `web-admin/src/views/pl/plan-archive/index.vue` | 归档列表页 | | `web-admin/src/router/modules/pl.ts` | 路由 | | `docs/sql/shanxia2.0.sql` | 菜单 + 按钮权限 | | `web-admin/src/utils/router/menu-icon.ts` | 菜单图标 | | `web-admin/src/views/pl/plan/index.vue` | 删除批量归档 | | `web-admin/src/views/pl/plan/detail.vue` | 删除归档按钮 | --- ### Task 1: 归档请求 Schema **Files:** - Modify: `backend/app/schemas/pl/plan.py` - Modify: `backend/app/schemas/pl/__init__.py`(若需 export) - [ ] **Step 1:** 在 `plan.py` 末尾、`PlanListResponse` 之前追加: ```python class PlanArchiveProductItem(BaseModel): id: str = Field(..., description="计划产品明细ID pl_plan_product.id") actual_qty: Decimal = Field(..., alias="actualQty", ge=0, description="实际数量") model_config = {"populate_by_name": True} class PlanArchiveFreightBlock(BaseModel): distance_km: Optional[Decimal] = Field(default=None, alias="distanceKm") freight_type: str = Field(default="by_weight", alias="freightType") freight_price: Optional[Decimal] = Field(default=None, alias="freightPrice") freight_amount: Optional[Decimal] = Field(default=None, alias="freightAmount") transport_qty: Optional[Decimal] = Field(default=None, alias="transportQty") has_invoice: Optional[int] = Field(default=None, alias="hasInvoice") model_config = {"populate_by_name": True} class PlanArchiveBody(BaseModel): products: list[PlanArchiveProductItem] = Field(..., min_length=1) freight: Optional[PlanArchiveFreightBlock] = None model_config = {"populate_by_name": True} ``` - [ ] **Step 2:** 验证 import 无循环 Run: `cd backend && source venv/bin/activate && python -c "from app.schemas.pl.plan import PlanArchiveBody; print('ok')"` Expected: `ok` --- ### Task 2: 归档业务 Service **Files:** - Modify: `backend/app/services/pl_plan_service.py` - [ ] **Step 1:** 新增 import: ```python from app.models.pl_models import PlFreightSettlement, PlFreightSettlementTransport, PlPlanChange, PlTransport from app.schemas.pl.plan import PlanArchiveFreightBlock, PlanArchiveProductItem ``` - [ ] **Step 2:** 追加 `transport_has_locked_freight_settlement`: ```python _LOCKED_SETTLEMENT_STATUSES = ("submitted", "approved", "confirmed") def transport_has_locked_freight_settlement( db: Session, tenant_no: str, transport_id: Optional[str] ) -> bool: if not transport_id: return False return ( db.query(PlFreightSettlementTransport.id) .join( PlFreightSettlement, PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id, ) .filter( PlFreightSettlementTransport.transport_id == transport_id, PlFreightSettlementTransport.tenant_no == tenant_no, PlFreightSettlementTransport.del_flag == 0, PlFreightSettlement.tenant_no == tenant_no, PlFreightSettlement.del_flag == 0, PlFreightSettlement.settlement_status.in_(_LOCKED_SETTLEMENT_STATUSES), ) .first() is not None ) ``` - [ ] **Step 3:** 追加 `apply_archive_product_quantities`: ```python def apply_archive_product_quantities( db: Session, *, tenant_no: str, plan: PlPlan, items: list[PlanArchiveProductItem], user_id: str, ) -> None: product_map = { row.id: row for row in db.query(PlPlanProduct).filter( PlPlanProduct.plan_id == plan.id, PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.del_flag == 0, ).all() } for item in items: prod = product_map.get(item.id) if not prod: raise HTTPException(status_code=400, detail=f"产品明细不存在: {item.id}") actual = Decimal(str(item.actual_qty)) prod.actual_qty = actual price_in = prod.inbound_price or prod.tax_price price_out = prod.outbound_price or prod.tax_price if plan.plan_type == "inbound": prod.inbound_qty = actual if price_in is not None: prod.inbound_amount = actual * Decimal(str(price_in)) elif plan.plan_type == "outbound": prod.outbound_qty = actual if price_out is not None: prod.outbound_amount = actual * Decimal(str(price_out)) prod.update_by = user_id ``` - [ ] **Step 4:** 追加 `apply_archive_freight`: ```python def apply_archive_freight( db: Session, *, tenant_no: str, plan: PlPlan, freight: PlanArchiveFreightBlock, user_id: str, ) -> None: if int(plan.has_freight or 0) != 1: return if not plan.transport_id: raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输") if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id): raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费") transport = ( db.query(PlTransport) .filter( PlTransport.id == plan.transport_id, PlTransport.tenant_no == tenant_no, PlTransport.del_flag == 0, ) .first() ) if not transport: raise HTTPException(status_code=400, detail="关联运输单不存在") transport.distance_km = freight.distance_km transport.freight_type = freight.freight_type or "by_weight" transport.freight_price = freight.freight_price amount = freight.freight_amount or Decimal("0") transport.freight_amount = amount if freight.transport_qty is not None: transport.transport_qty = freight.transport_qty if freight.has_invoice is not None: transport.has_invoice = freight.has_invoice transport.update_by = user_id plan.freight_amount = amount plan.update_by = user_id ``` - [ ] **Step 5:** 追加 `record_plan_change`: ```python def record_plan_change( db: Session, *, tenant_no: str, plan_id: str, change_type: str, change_content: str, user_id: str, user_name: Optional[str], dept_id: str, ) -> None: db.add( PlPlanChange( id=generate_uuid(), tenant_no=tenant_no, plan_id=plan_id, change_type=change_type, change_content=change_content, change_user_id=user_id, change_user_name=user_name, create_by=user_id, create_dept_id=dept_id, ) ) ``` - [ ] **Step 6:** 验证 import Run: `cd backend && python -c "from app.services.pl_plan_service import apply_archive_product_quantities; print('ok')"` Expected: `ok` --- ### Task 3: 增强 `archive_plan` + 放宽 `update_plan` **Files:** - Modify: `backend/app/api/pl/plan.py` - [ ] **Step 1:** 扩展 import(在文件顶部 `from fastapi import ...` 增加 `Body`): ```python from app.schemas.pl.plan import PlanArchiveBody, PlanWithProducts from app.services.pl_plan_service import ( apply_archive_freight, apply_archive_product_quantities, record_plan_change, ) ``` - [ ] **Step 2:** 在 `update_plan` 中**删除**以下阻断(约 L589–591): ```python if plan.archive_status == "archived": raise HTTPException(status_code=400, detail="已归档的计划不能修改") ``` - [ ] **Step 3:** 替换 `archive_plan` 为: ```python @router.post("/{id}/archive", response_model=PlanWithProducts) def archive_plan( id: str, payload: Optional[PlanArchiveBody] = Body(default=None), db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> PlanWithProducts: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) plan = _get_plan(db, tenant_no, id) if plan.follow_status == "cancelled": raise HTTPException(status_code=400, detail="已作废的计划不能归档") already_archived = plan.archive_status == "archived" # 无 body:向后兼容,仅首次改状态 if payload is None: if already_archived: raise HTTPException(status_code=400, detail="计划已归档,请提交归档表单以调整内容") plan.archive_status = "archived" plan.complete_status = "yes" plan.follow_status = "completed" plan.update_by = user.id db.commit() return get_plan(id, db, user) apply_archive_product_quantities( db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id ) if payload.freight is not None: apply_archive_freight( db, tenant_no=tenant_no, plan=plan, freight=payload.freight, user_id=user.id ) elif int(plan.has_freight or 0) == 1 and not already_archived: if not plan.transport_id: raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输") if not already_archived: plan.archive_status = "archived" plan.complete_status = "yes" plan.follow_status = "completed" plan.update_by = user.id record_plan_change( db, tenant_no=tenant_no, plan_id=plan.id, change_type="archive_adjust" if already_archived else "archive", change_content="调整归档内容" if already_archived else "运输计划归档", user_id=user.id, user_name=user.real_name or user.username, dept_id=user.dept_id, ) db.commit() return get_plan(id, db, user) ``` - [ ] **Step 4:** 确认 `get_plan` 在同文件已存在且返回 `PlanWithProducts`(复用现有 GET 逻辑) - [ ] **Step 5:** 验证路由加载 Run: `cd backend && python -c "from app.api.pl.plan import archive_plan; print('ok')"` Expected: `ok` --- ### Task 4: 前端 API 类型 **Files:** - Modify: `web-admin/src/api/pl/index.ts` - [ ] **Step 1:** 追加类型: ```typescript export interface PlanArchiveProductItem { id: string actualQty: number } export interface PlanArchiveFreightBlock { distanceKm?: number freightType?: string freightPrice?: number freightAmount?: number transportQty?: number hasInvoice?: number } export interface PlanArchiveBody { products: PlanArchiveProductItem[] freight?: PlanArchiveFreightBlock } ``` - [ ] **Step 2:** 扩展 `PlanQuery`(若尚未有): ```typescript archiveStatus?: string ``` - [ ] **Step 3:** 修改 `planApi.archive`: ```typescript archive: (id: string, data?: PlanArchiveBody) => request.post(`/transport/plan/${id}/archive`, data), ``` --- ### Task 5: 产品表 `editableActualQtyOnly` **Files:** - Modify: `web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue` - [ ] **Step 1:** props 增加: ```typescript /** 仅实际数量可编辑,计划数量只读(归档弹窗) */ editableActualQtyOnly?: boolean ``` - [ ] **Step 2:** 计划数量列 template 改为: ```vue ``` - [ ] **Step 3:** 实际数量列 template 改为: ```vue ``` --- ### Task 6: `PlPlanArchiveDialog` **Files:** - Create: `web-admin/src/components/business/pl-plan-archive-dialog/index.vue` - Reference: `web-admin/src/components/business/pl-plan-arrange-dialog/index.vue`(布局 SCSS 类 `pl-arrange-layout` 等可复用) - [ ] **Step 1:** 创建弹窗骨架(props: `visible`, `planId`;emit: `update:visible`, `success`) - [ ] **Step 2:** 上区表单: - `PlPlanFormProductTable`:`show-actual-qty` + `editable-actual-qty-only` - 运费区块:从 arrange-dialog 复制 L189–236 结构,绑定 `formData` + `freightCalc` - `usePlFreightCalc({ products: productRows, hasFreight, freightType })` - [ ] **Step 3:** 下区:`PlPlanViewTabs ref="tabsRef" :plan-id="planId" embedded` - [ ] **Step 4:** 计算属性: ```typescript const isAdjustMode = computed(() => plan.value.archiveStatus === 'archived') const dialogTitle = computed(() => { const no = plan.value.planNo || '' return isAdjustMode.value ? no ? `调整归档内容 · ${no}` : '调整归档内容' : no ? `运输计划归档 · ${no}` : '运输计划归档' }) const submitLabel = computed(() => (isAdjustMode.value ? '确认保存' : '确认归档')) ``` - [ ] **Step 5:** `handleOpened`:加载 `planApi.get(planId)`,映射 products → `productRows`,从 plan/transport 预填运费字段 - [ ] **Step 6:** `handleSubmit`: ```typescript const body: PlanArchiveBody = { products: productRows.value.map(row => ({ id: row.productId, // 注意:用 pl_plan_product.id,不是 CRM productId actualQty: Number(row.actualQty ?? 0) })), freight: formData.hasFreight === 1 ? { distanceKm: formData.distanceKm ?? undefined, freightType: formData.freightType, freightPrice: freightCalc.freightPrice.value ?? undefined, freightAmount: freightCalc.freightAmount.value ?? undefined, transportQty: freightCalc.transportQty.value ?? undefined, hasInvoice: formData.hasInvoice } : undefined } await planApi.archive(props.planId, body) ElMessage.success(isAdjustMode.value ? '保存成功' : '归档成功') tabsRef.value?.reload?.() emit('success') innerVisible.value = false ``` > **注意:** `productRows` 映射时必须保留 `pl_plan_product.id`(从 API `PlanProduct.id`),勿与 `productId` 混淆。 - [ ] **Step 7:** 样式:复用 arrange-dialog 的 `pl-arrange-layout` / `pl-arrange-form-panel` / `pl-arrange-view-panel`(可在 scoped 中 `@use` 或复制最小 SCSS) --- ### Task 7: `usePlPlanList` composable **Files:** - Create: `web-admin/src/composables/usePlPlanList.ts` - Modify: `web-admin/src/views/pl/plan/index.vue`(可选,YAGNI:若时间紧可只在 plan-archive 复制,后续再抽) - [ ] **Step 1:** 抽取共享状态与方法: ```typescript export function usePlPlanList(options?: { extraSearchFields?: () => Record }) { const loading = ref(false) const planList = ref([]) const selectedRows = ref([]) const dateRange = ref([]) const filterExpanded = ref(false) const searchForm = reactive({ planNo: '', planType: '', followStatus: '', transportStatus: '', customerName: '', archiveStatus: '' // 归档页使用 }) const pagination = reactive({ page: 1, pageSize: 20, total: 0 }) async function fetchData() { /* planApi.list 参数组装 */ } function handleSearch() { pagination.page = 1; fetchData() } function handleReset() { /* 清空 + fetchData */ } // ... handleSelectionChange, handlePageChange, handleSizeChange return { loading, planList, selectedRows, dateRange, filterExpanded, searchForm, pagination, fetchData, handleSearch, handleReset, ... } } ``` - [ ] **Step 2:** (可选)`plan/index.vue` 改用 composable,确保行为不变 --- ### Task 8: 归档列表页 **Files:** - Create: `web-admin/src/views/pl/plan-archive/index.vue` - [ ] **Step 1:** 复制 `plan/index.vue` 筛选区 + 表格列,标题改为 `运输计划归档` - [ ] **Step 2:** 展开区增加「归档状态」: ```vue ``` - [ ] **Step 3:** 工具栏:保留「已选 N 项」;**不放置「批量归档」**(归档必须经弹窗填写 actualQty/运费,无法无表单批量) - [ ] **Step 4:** 操作列(`min-width="260"`): ```vue 查看 编辑 {{ row.archiveStatus === 'archived' ? '调整' : '归档' }} ``` - [ ] **Step 5:** 挂载 `PlPlanViewDialog`、`PlPlanFormDialog`、`PlPlanArchiveDialog` - [ ] **Step 6:** `handleBatchArchive`:**不实现**(见 Step 3) - [ ] **Step 7:** `onMounted` → `fetchData()` --- ### Task 9: 路由、菜单、标题竖条 **Files:** - Modify: `web-admin/src/router/modules/pl.ts` - Modify: `docs/sql/shanxia2.0.sql` - Modify: `web-admin/src/utils/router/menu-icon.ts` - Modify: `web-admin/src/App.vue`(若存在 `panelTitleBarTextRules`)或确认 `app.scss` 对 `运输计划归档` 标题生效 - [ ] **Step 1:** `pl.ts` 在 `PlPlan` 后插入: ```typescript { id: 'PL00000000000000000000000000016', name: 'PlPlanArchive', path: 'plan-archive', component: '/pl/plan-archive/index', meta: { title: '运输计划归档', keepAlive: true, menuCode: 'pl:plan-archive:list' } } ``` - [ ] **Step 2:** `shanxia2.0.sql` 追加菜单与按钮(`order_num=12`,危化品证上传改为 13): ```sql INSERT INTO `sys_menu` (...) VALUES ('PL_PLAN_ARCHIVE', '运输计划归档', 'PL_MENU_ROOT', 'C', 'plan-archive', 'pl/plan-archive/index', 'pl:plan-archive:list', 1, '1', '1', 'shanxia2.0', 12, NULL, 'system', '', NOW(), NULL, NOW(), 0); INSERT INTO `sys_menu` (...) VALUES ('PL_PLAN_ARCHIVE_Q', '查询', 'PL_PLAN_ARCHIVE', 'F', '', '', 'pl:plan-archive:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0), ('PL_PLAN_ARCHIVE_E', '编辑', 'PL_PLAN_ARCHIVE', 'F', '', '', 'pl:plan-archive:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0), ('PL_PLAN_ARCHIVE_A', '归档', 'PL_PLAN_ARCHIVE', 'F', '', '', 'pl:plan-archive:archive', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0); ``` - [ ] **Step 3:** `menu-icon.ts`: ```typescript 'pl:plan-archive:list': 'ri:folder-check-line', ``` - [ ] **Step 4:** 页面 `h2.title-bar-heading` 文案为 **运输计划归档**(与规范竖条一致) --- ### Task 10: 原运输计划页移除归档 **Files:** - Modify: `web-admin/src/views/pl/plan/index.vue` - Modify: `web-admin/src/views/pl/plan/detail.vue` - [ ] **Step 1:** `plan/index.vue` 删除工具栏「批量归档」按钮、`handleBatchArchive`、`FolderChecked` import(无其他引用时) - [ ] **Step 2:** `plan/detail.vue` 删除「归档」按钮、`handleArchive` 函数、`FolderChecked` import(无其他引用时) --- ### Task 11: 联调验收 - [ ] **V1** 打开 `/#/pl/plan-archive` — 列表与筛选正常,含归档状态筛选项 - [ ] **V2** 未归档行 — 查看 / 编辑 / 归档可用 - [ ] **V3** 已归档行 — 查看 / 编辑 / 调整可用 - [ ] **V5** 首次归档 — actualQty、运费、状态变为 archived/completed - [ ] **V6** 已归档调整 — 仅数据更新,状态保持 archived - [ ] **V6a** 已归档编辑 — `PlPlanFormDialog` 保存成功 - [ ] **V7** `/#/pl/plan` 无批量归档;详情无归档按钮 - [ ] **V9** 有运费未安排运输 — 归档 400 - [ ] **V10** 按重量 — actualQty 变更联动运输量 - [ ] **V11** 下区司机/现场链接可复制 Run frontend lint(改动文件): ```bash cd web-admin && pnpm lint ``` Run backend import check: ```bash cd backend && python -c "from app.main import app; print('ok')" ``` Run graphify update: ```bash cd /Users/zlwu/work/00code/shanxia2.0 && ./scripts/update-graph.sh all ``` --- ## Spec 覆盖自检 | Spec 条目 | Task | |-----------|------| | D1 全部计划列表 | Task 8 | | D2 已归档可调整 | Task 3, 6, 8 | | D2a 已归档可编辑 | Task 3, 8 | | D3 原页移除归档 | Task 10 | | D4 弹窗布局 | Task 6 | | D5 增强 API | Task 1–3 | | D6 筛选一致 + 归档状态 | Task 7–8 | | V1–V12 验收 | Task 11 | --- ## 执行顺序建议 1. Task 1 → 2 → 3(后端可先联调 Swagger) 2. Task 4 → 5 → 6(归档弹窗) 3. Task 7 → 8 → 9(列表 + 路由菜单) 4. Task 10 → 11(清理 + 验收)