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

659 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 运输计划归档 — 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<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 增加:
```typescript
/** 仅实际数量可编辑,计划数量只读(归档弹窗) */
editableActualQtyOnly?: boolean
```
- [ ] **Step 2:** 计划数量列 template 改为:
```vue
<el-input-number
v-if="editableQty && !editableActualQtyOnly"
v-model="row.quantity"
...
/>
```
- [ ] **Step 3:** 实际数量列 template 改为:
```vue
<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:** 计算属性:
```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<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:** 展开区增加「归档状态」:
```vue
<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"`):
```vue
<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` 后插入:
```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(清理 + 验收)