21 KiB
运输计划归档 — 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(从 APIPlanProduct.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、FolderCheckedimport(无其他引用时) -
Step 2:
plan/detail.vue删除「归档」按钮、handleArchive函数、FolderCheckedimport(无其他引用时)
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 |
执行顺序建议
- Task 1 → 2 → 3(后端可先联调 Swagger)
- Task 4 → 5 → 6(归档弹窗)
- Task 7 → 8 → 9(列表 + 路由菜单)
- Task 10 → 11(清理 + 验收)