Files
shanxia2.0/docs/superpowers/plans/2026-06-25-pl-plan-archive.md
T
2026-06-25 08:06:32 +08:00

21 KiB
Raw Blame History

运输计划归档 — 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 之前追加:

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:

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:
_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:
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:
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:
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):

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):
if plan.archive_status == "archived":
    raise HTTPException(status_code=400, detail="已归档的计划不能修改")
  • Step 3: 替换 archive_plan 为:
@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: 追加类型:

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(若尚未有):
archiveStatus?: string
  • Step 3: 修改 planApi.archive:
archive: (id: string, data?: PlanArchiveBody) =>
  request.post<Plan>(`/transport/plan/${id}/archive`, data),

Task 5: 产品表 editableActualQtyOnly

Files:

  • Modify: web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue

  • Step 1: props 增加:

/** 仅实际数量可编辑,计划数量只读(归档弹窗) */
editableActualQtyOnly?: boolean
  • Step 2: 计划数量列 template 改为:
<el-input-number
  v-if="editableQty && !editableActualQtyOnly"
  v-model="row.quantity"
  ...
/>
  • Step 3: 实际数量列 template 改为:
<el-input-number
  v-if="(editableQty && showActualQty) || editableActualQtyOnly"
  v-model="row.actualQty"
  ...
/>

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: 计算属性:

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:

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: 抽取共享状态与方法:

export function usePlPlanList(options?: { extraSearchFields?: () => Record<string, unknown> }) {
  const loading = ref(false)
  const planList = ref<Plan[]>([])
  const selectedRows = ref<Plan[]>([])
  const dateRange = ref<string[]>([])
  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: 展开区增加「归档状态」:

<el-form-item label="归档状态">
  <el-select v-model="searchForm.archiveStatus" placeholder="全部" clearable>
    <el-option label="未归档" value="unarchived" />
    <el-option label="已归档" value="archived" />
  </el-select>
</el-form-item>
  • Step 3: 工具栏:保留「已选 N 项」;不放置「批量归档」(归档必须经弹窗填写 actualQty/运费,无法无表单批量)

  • Step 4: 操作列(min-width="260"):

<el-button link type="primary" @click="handleView(row)">
  <el-icon><View /></el-icon>查看
</el-button>
<el-button
  v-if="row.followStatus !== 'cancelled'"
  link type="primary"
  @click="handleEdit(row)"
>
  <el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
  v-if="row.followStatus !== 'cancelled'"
  link type="success"
  @click="handleArchive(row)"
>
  <el-icon><FolderChecked /></el-icon>{{ row.archiveStatus === 'archived' ? '调整' : '归档' }}
</el-button>
  • 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 后插入:

{
  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):
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:
'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(改动文件):

cd web-admin && pnpm lint

Run backend import check:

cd backend && python -c "from app.main import app; print('ok')"

Run graphify update:

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(清理 + 验收)