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

544 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:** 将运费结算重构为四 Tab(未结算计划 / 结算单 / 待财务发放 / 已结算),支持多计划组单、合同式多级审批、财务发放登记。
**Architecture:** 新建 `pl_freight_settlement_approval` 与 `pl_freight_settlement_service.py`(审批链 CRUD 对齐 `contract_approval.py`);`freight.py` 路由薄层调用 service;前端 `freight/index.vue` 改为 Tab 容器 + 四个 Panel 子组件 + 三个业务弹窗(查看/审批/发放)。
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + 字典 Store
**Spec:** `docs/superpowers/specs/2026-06-25-pl-freight-settlement-design.md`
---
## 文件结构
| 文件 | 职责 |
|------|------|
| `docs/sql/20260625_pl_freight_settlement_approval.sql` | 审批表 DDL + 字典 + 按钮权限 |
| `backend/app/models/pl_models.py` | `PlFreightSettlementApproval` |
| `backend/app/schemas/pl/freight.py` | 组单/审批/发放/列表扩展 Schema |
| `backend/app/services/pl_freight_settlement_service.py` | 未结算查询、组单、审批、发放 |
| `backend/app/api/pl/freight.py` | 新路由;废弃旧 submit/approve |
| `backend/app/services/pl_plan_service.py` | 更新 `_LOCKED_SETTLEMENT_STATUSES` |
| `web-admin/src/api/pl/index.ts` | `freightApi` 扩展 |
| `web-admin/src/views/pl/freight/index.vue` | Tab 容器 |
| `web-admin/src/views/pl/freight/components/UnsettledPlanPanel.vue` | Tab1 |
| `web-admin/src/views/pl/freight/components/SettlementBillPanel.vue` | Tab2 |
| `web-admin/src/views/pl/freight/components/DisbursePanel.vue` | Tab3 |
| `web-admin/src/views/pl/freight/components/SettledPanel.vue` | Tab4 |
| `web-admin/src/components/business/pl-freight-settlement-view-dialog/index.vue` | 查看 |
| `web-admin/src/components/business/pl-freight-settlement-form-dialog/index.vue` | 草稿编辑 |
| `web-admin/src/components/business/pl-freight-approval-dialog/index.vue` | 提交/审批 |
| `web-admin/src/components/business/pl-freight-disburse-dialog/index.vue` | 财务发放 |
---
### Task 1: 数据库 DDL + 字典 + 菜单权限
**Files:**
- Create: `docs/sql/20260625_pl_freight_settlement_approval.sql`
- Modify: `docs/sql/pl_tables.sql`(同步表结构)
- Modify: `docs/sql/shanxia2.0.sql`(全量快照,可选后续导出)
- [ ] **Step 1:** 创建独立 SQL 脚本,含:
```sql
-- pl_freight_settlement_approval
CREATE TABLE IF NOT EXISTS pl_freight_settlement_approval (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
settlement_id VARCHAR(64) NOT NULL COMMENT '结算单ID',
step INT NOT NULL DEFAULT 1 COMMENT '步骤序号',
step_name VARCHAR(50) NULL COMMENT '步骤名称',
approver_id VARCHAR(64) NOT NULL COMMENT '审批人ID',
approver_name VARCHAR(64) NULL COMMENT '审批人姓名',
approval_type VARCHAR(20) NOT NULL DEFAULT 'sequential' COMMENT 'sequential/parallel/or',
approval_result VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/approved/rejected',
approval_opinion VARCHAR(500) NULL COMMENT '审批意见',
approval_time DATETIME NULL COMMENT '审批时间',
is_current TINYINT NOT NULL DEFAULT 0 COMMENT '是否当前节点',
tenant_no VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_pl_fsa_settlement (settlement_id, del_flag),
KEY idx_pl_fsa_approver (approver_id, approval_result, is_current, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运费结算审批记录';
```
- [ ] **Step 2:** 同一脚本更新 `pl_settlement_status` 字典:
```sql
-- 新增/更新
INSERT INTO sys_dict_data (...) VALUES
('PL_SETTLE_APPROVING', '审批中', 'approving', 'warning', 'pl_settlement_status', ...),
('PL_SETTLE_APPROVED', '待发放', 'approved', 'primary', 'pl_settlement_status', ...);
-- 更新 pending 标签为「草稿」、confirmed 为「已结算」
```
- [ ] **Step 3:** 按钮权限(parent `PL_FREIGHT`):
| id | menu_name | perms |
|----|-----------|-------|
| PL_FREIGHT_Q | 查询 | pl:freight:query |
| PL_FREIGHT_C | 组单 | pl:freight:create |
| PL_FREIGHT_E | 编辑 | pl:freight:edit |
| PL_FREIGHT_S | 提交审批 | pl:freight:submit |
| PL_FREIGHT_A | 审批 | pl:freight:approve |
| PL_FREIGHT_W | 撤回 | pl:freight:withdraw |
| PL_FREIGHT_D | 发放登记 | pl:freight:disburse |
- [ ] **Step 4:** 在目标库执行脚本并验证表存在
Run: `mysql ... -e "SHOW TABLES LIKE 'pl_freight_settlement_approval';"`
Expected: 1 row
---
### Task 2: ORM 模型
**Files:**
- Modify: `backend/app/models/pl_models.py`
- [ ] **Step 1:** 在 `PlRemittanceVoucher` 之前追加:
```python
class PlFreightSettlementApproval(Base):
"""运费结算审批 pl_freight_settlement_approval"""
__tablename__ = "pl_freight_settlement_approval"
id = Column(String(64), primary_key=True)
settlement_id = Column(String(64), nullable=False)
step = Column(Integer, nullable=False, default=1)
step_name = Column(String(50), nullable=True)
approver_id = Column(String(64), nullable=False)
approver_name = Column(String(64), nullable=True)
approval_type = Column(String(20), nullable=False, default="sequential")
approval_result = Column(String(20), nullable=False, default="pending")
approval_opinion = Column(String(500), nullable=True)
approval_time = Column(DateTime, nullable=True)
is_current = Column(SmallInteger, nullable=False, default=0)
# ... 公共字段 tenant_no ~ del_flag 与其他 pl 表一致
```
- [ ] **Step 2:** 验证 import
Run: `cd backend && python3 -c "from app.models.pl_models import PlFreightSettlementApproval; print('ok')"`
Expected: `ok`
---
### Task 3: Pydantic Schema 扩展
**Files:**
- Modify: `backend/app/schemas/pl/freight.py`
- [ ] **Step 1:** 扩展 `FreightSettlementCreate` 支持 `planIds`:
```python
class FreightSettlementCreate(BaseModel):
transport_ids: Optional[list[str]] = Field(default=None, alias="transportIds")
plan_ids: Optional[list[str]] = Field(default=None, alias="planIds")
model_config = {"populate_by_name": True}
@model_validator(mode="after")
def check_ids(self):
if not self.transport_ids and not self.plan_ids:
raise ValueError("transportIds 或 planIds 至少提供一个")
return self
```
- [ ] **Step 2:** 列表项扩展 `FreightSettlementListItem`:
```python
class FreightSettlementListItem(FreightSettlementRead):
plan_count: int = Field(default=0, alias="planCount")
license_plate_summary: Optional[str] = Field(default=None, alias="licensePlateSummary")
current_approver_name: Optional[str] = Field(default=None, alias="currentApproverName")
can_approve: bool = Field(default=False, alias="canApprove")
can_withdraw: bool = Field(default=False, alias="canWithdraw")
approval_finished_time: Optional[datetime] = Field(default=None, alias="approvalFinishedTime")
disburse_time: Optional[datetime] = Field(default=None, alias="disburseTime")
disburse_amount: Optional[Decimal] = Field(default=None, alias="disburseAmount")
```
- [ ] **Step 3:** 审批/发放 body:
```python
class FreightSettlementSubmitApprovalBody(BaseModel):
approval_type: str = Field(default="sequential", alias="approvalType") # sequential|parallel|or
approver_ids: list[str] = Field(..., alias="approverIds", min_length=1)
remark: Optional[str] = None
model_config = {"populate_by_name": True}
class FreightSettlementApprovalActionBody(BaseModel):
action: str = Field(..., description="approve|reject")
comment: Optional[str] = None
class FreightSettlementDisburseBody(BaseModel):
remittance_date: date = Field(..., alias="remittanceDate")
remittance_amount: Decimal = Field(..., alias="remittanceAmount", gt=0)
voucher_urls: list[str] = Field(..., alias="voucherUrls", min_length=1)
remark: Optional[str] = None
model_config = {"populate_by_name": True}
```
- [ ] **Step 4:** `FreightSettlementWithTransports` 增加 `plans`, `approvalFlow`, `vouchers`, `canApprove`
Run: `python3 -c "from app.schemas.pl.freight import FreightSettlementDisburseBody; print('ok')"`
---
### Task 4: Service — 未结算计划 + 组单
**Files:**
- Create: `backend/app/services/pl_freight_settlement_service.py`
- [ ] **Step 1:** 实现 `list_unsettled_plans(db, tenant_no, query, page, page_size)`:
逻辑(spec D5):
```python
def list_unsettled_plans(...):
# pl_plan JOIN pl_transport ON plan.transport_id = transport.id
# WHERE archive_status='archived' AND has_freight=1 AND follow_status!='cancelled'
# AND transport.freight_amount > 0
# AND transport_id NOT IN (
# SELECT st.transport_id FROM pl_freight_settlement_transport st
# JOIN pl_freight_settlement s ON s.id=st.settlement_id
# WHERE s.del_flag=0 AND st.del_flag=0
# ) -- 任何 pending 或非 pending 占用均排除;删草稿后重新可见
```
- [ ] **Step 2:** 实现 `create_settlement_from_plan_ids(db, tenant_no, plan_ids, user)`:
```python
def create_settlement_from_plan_ids(...):
plans = ... # 校验全部 archived + has_freight + transport_id
transport_ids = [p.transport_id for p in plans]
_assert_transports_not_occupied(db, tenant_no, transport_ids)
total = sum(transport.freight_amount)
settlement = PlFreightSettlement(settlement_status="pending", ...)
for t in transports:
db.add(PlFreightSettlementTransport(...))
db.commit()
return settlement
```
- [ ] **Step 3:** 实现 `delete_draft_settlement` — 仅 `pending`,软删 settlement + transport links
- [ ] **Step 4:** 实现 `_assert_transports_not_occupied` — 被任何未删 settlement 占用则 400
---
### Task 5: Service — 审批链(对齐 contract_approval)
**Files:**
- Modify: `backend/app/services/pl_freight_settlement_service.py`
- [ ] **Step 1:** `create_settlement_approval_records(db, settlement_id, approval_type, approver_ids, user)`
参考 `contract_approval.create_approval_records`:
- sequential:step 递增,首个 `is_current=1`
- parallel:同 step 多人,`is_current=1` 全部
- or:同 step 多人,任一通过即可
- [ ] **Step 2:** `can_user_approve_settlement(db, settlement_id, user_id) -> bool`
- [ ] **Step 3:** `submit_settlement_approval(...)` — `pending → approving`,写审批记录
- [ ] **Step 4:** `process_settlement_approval(db, approval_id, action, comment, user)`:
通过:推进下一节点 / 会签计数 / 或签短路 → 全部完成则 `settlement_status='approved'`
驳回:`settlement_status='pending'`,当前链 `approval_result='rejected'`,`is_current=0`
- [ ] **Step 5:** `withdraw_settlement_approval(...)` — 仅提交人、`approving` 态,软删 pending 记录,回 `pending`
- [ ] **Step 6:** `get_settlement_approval_flow(db, settlement_id) -> list[dict]`
---
### Task 6: Service — 财务发放 + 列表 enrich
**Files:**
- Modify: `backend/app/services/pl_freight_settlement_service.py`
- [ ] **Step 1:** `disburse_settlement(db, settlement_id, body, user)`:
```python
def disburse_settlement(...):
settlement = _get_settlement(...)
if settlement.settlement_status != "approved":
raise HTTPException(400, "仅待发放状态可登记")
if body.remittance_amount != settlement.total_freight and not (body.remark or "").strip():
raise HTTPException(400, "汇款金额与运费合计不一致时备注必填")
voucher = PlRemittanceVoucher(
settlement_id=settlement.id,
remittance_date=body.remittance_date,
remittance_amount=body.remittance_amount,
voucher_urls=json.dumps(body.voucher_urls),
remark=body.remark,
create_by=user.id,
...
)
settlement.settlement_status = "confirmed"
settlement.update_by = user.id
db.commit()
```
- [ ] **Step 2:** `list_settlements_enriched(...)` — 按 `settlement_status` 或 `tab` 过滤,附加 planCount、canApprove、currentApproverName
- [ ] **Step 3:** `get_settlement_detail(...)` — transports + 关联 plans + approvalFlow + vouchers
---
### Task 7: 更新运费锁定状态
**Files:**
- Modify: `backend/app/services/pl_plan_service.py`
- [ ] **Step 1:** 替换 `_LOCKED_SETTLEMENT_STATUSES`:
```python
_LOCKED_SETTLEMENT_STATUSES = ("approving", "approved", "confirmed")
```
- [ ] **Step 2:** 组单占用逻辑:任何 `del_flag=0` 的 settlement 关联均视为占用(含 pending),与 Tab1 查询一致
---
### Task 8: API 路由重构
**Files:**
- Modify: `backend/app/api/pl/freight.py`
- [ ] **Step 1:** 新增路由:
| 路由 | 处理函数 |
|------|----------|
| `GET /unsettled-plans` | `list_unsettled_plans` |
| `GET /` | `list_settlements_enriched`,query: `tab=bills|disburse|settled` 或 `settlementStatus` |
| `POST /` | body `planIds` → `create_settlement_from_plan_ids` |
| `DELETE /{id}` | `delete_draft_settlement` |
| `POST /{id}/submit-approval` | `submit_settlement_approval` |
| `POST /approval/{approval_id}` | `process_settlement_approval` |
| `POST /{id}/withdraw-approval` | `withdraw_settlement_approval` |
| `GET /{id}/approval-flow` | `get_settlement_approval_flow` |
| `POST /{id}/disburse` | `disburse_settlement` |
- [ ] **Step 2:** 保留旧 `POST /{id}/submit`、`POST /{id}/approve` 为 deprecated 别名(内部转新逻辑或 410),避免破坏未知调用
- [ ] **Step 3:** `GET /{id}` 返回 enrich 详情
Run: `python3 -c "from app.main import app; print('ok')"`
---
### Task 9: 前端 API 类型
**Files:**
- Modify: `web-admin/src/api/pl/index.ts`
- [ ] **Step 1:** 扩展 `FreightSettlement` 类型字段:`planCount`, `licensePlateSummary`, `currentApproverName`, `canApprove`, `canWithdraw`, `approvalFinishedTime`, `disburseTime`, `disburseAmount`
- [ ] **Step 2:** 新增 `UnsettledPlan` 类型与 `freightApi.unsettledPlans(params)`
- [ ] **Step 3:** 更新 freightApi 方法:
```typescript
export const freightApi = {
unsettledPlans: (params) => request.get('/transport/freight/unsettled-plans', { params }),
list: (params: { tab?: 'bills' | 'disburse' | 'settled'; ... }) => ...,
create: (data: { planIds: string[] }) => request.post('/transport/freight/', data),
remove: (id: string) => request.delete(`/transport/freight/${id}`),
submitApproval: (id, data) => request.post(`/transport/freight/${id}/submit-approval`, data),
processApproval: (approvalId, data) => request.post(`/transport/freight/approval/${approvalId}`, data),
withdrawApproval: (id) => request.post(`/transport/freight/${id}/withdraw-approval`),
approvalFlow: (id) => request.get(`/transport/freight/${id}/approval-flow`),
disburse: (id, data) => request.post(`/transport/freight/${id}/disburse`, data),
// get, update 保留
}
```
---
### Task 10: Tab 容器 `freight/index.vue`
**Files:**
- Modify: `web-admin/src/views/pl/freight/index.vue`
- [ ] **Step 1:** 重构为 P08 结构:
```vue
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">运费结算</h2></div>
<el-tabs v-model="activeTab" class="freight-tabs" @tab-change="handleTabChange">
<el-tab-pane label="未结算计划" name="unsettled" />
<el-tab-pane label="结算单" name="bills" />
<el-tab-pane label="待财务发放" name="disburse" />
<el-tab-pane label="已结算" name="settled" />
</el-tabs>
<UnsettledPlanPanel v-if="activeTab === 'unsettled'" @created="onSettlementCreated" />
<SettlementBillPanel v-else-if="activeTab === 'bills'" ref="billsRef" />
<DisbursePanel v-else-if="activeTab === 'disburse'" />
<SettledPanel v-else-if="activeTab === 'settled'" />
</section>
</div>
</template>
```
- [ ] **Step 2:** `onSettlementCreated` 切换 `activeTab='bills'` 并 refresh
- [ ] **Step 3:** 删除原单列表逻辑(迁移到 Panel)
---
### Task 11: Tab1 `UnsettledPlanPanel.vue`
**Files:**
- Create: `web-admin/src/views/pl/freight/components/UnsettledPlanPanel.vue`
- [ ] **Step 1:** 筛选:计划编号、日期范围、车牌、单据类型(参考 `plan/index.vue` subset)
- [ ] **Step 2:** 表格 + selection + 工具栏「生成结算单」`type="primary"` + 已选 N 项
- [ ] **Step 3:** `handleCreateSettlement` → `freightApi.create({ planIds })` → emit `created`
- [ ] **Step 4:** 行操作「查看」→ `PlPlanViewDialog` 或现有 plan 查看组件
---
### Task 12: Tab2 `SettlementBillPanel.vue` + 草稿/查看弹窗
**Files:**
- Create: `web-admin/src/views/pl/freight/components/SettlementBillPanel.vue`
- Create: `web-admin/src/components/business/pl-freight-settlement-form-dialog/index.vue`
- Create: `web-admin/src/components/business/pl-freight-settlement-view-dialog/index.vue`
- [ ] **Step 1:** 列表 `freightApi.list({ tab: 'bills' })`,列按 spec §3.2
- [ ] **Step 2:** 行操作:
- `pending`:查看、编辑、提交审批、删除
- `approving`:查看、撤回(`canWithdraw`)、审批(`canApprove`)
- [ ] **Step 3:** 编辑弹窗 — 发票字段 + 只读明细表 + footer 取消/保存
- [ ] **Step 4:** 查看弹窗 — Hero + 结算信息 + 关联计划/运输表 + 审批时间线(无凭证)
- [ ] **Step 5:** 注册 `pl_settlement_status` 到 dictStore,Tag 渲染
---
### Task 13: 审批弹窗 `PlFreightApprovalDialog`
**Files:**
- Create: `web-admin/src/components/business/pl-freight-approval-dialog/index.vue`
- [ ] **Step 1:** Fork `crm/contract/components/ApprovalDialog.vue` 结构与样式
- [ ] **Step 2:** `actionType`: `submit` | `approve` | `reject`
- [ ] **Step 3:** submit → `freightApi.submitApproval(id, { approvalType, approverIds, remark })`
- [ ] **Step 4:** approve/reject → 先 `GET approval-flow` 取当前用户 pending 记录 id → `freightApi.processApproval(approvalId, { action, comment })`
- [ ] **Step 5:** SettlementBillPanel 打开弹窗;**禁止**顶栏批量审批
---
### Task 14: Tab3 `DisbursePanel` + 发放弹窗
**Files:**
- Create: `web-admin/src/views/pl/freight/components/DisbursePanel.vue`
- Create: `web-admin/src/components/business/pl-freight-disburse-dialog/index.vue`
- [ ] **Step 1:** 列表 `tab: 'disburse'`,列:结算单号、运费合计、审批完成时间、提交人、车牌摘要
- [ ] **Step 2:** 行操作:查看、发放登记(`v-hasPermi` 或 menuCode 校验 `pl:freight:disburse`)
- [ ] **Step 3:** 发放弹窗:
- 只读区:结算单号、合计、明细摘要、审批流
- 表单:汇款日期、汇款金额、附件卡片上传(`fetchUploadAttachment`)、备注
- 金额 ≠ 合计时前端校验备注必填
- [ ] **Step 4:** 提交 → `freightApi.disburse` → 刷新 Tab3;可选提示切换 Tab4
---
### Task 15: Tab4 `SettledPanel.vue`
**Files:**
- Create: `web-admin/src/views/pl/freight/components/SettledPanel.vue`
- [ ] **Step 1:** 列表 `tab: 'settled'`,增加汇款日期/金额/确认人/时间列
- [ ] **Step 2:** 仅「查看」— 复用 view-dialog,展示 voucher 附件预览/下载
---
### Task 16: 菜单权限 SQL(独立脚本)
**Files:**
- Create: `docs/sql/20260625_pl_freight_menu_permissions.sql`
- [ ] **Step 1:** 与 Task 1 Step 3 按钮权限一致,`ON DUPLICATE KEY UPDATE`
- [ ] **Step 2:** 可选:为已有 `PL_FREIGHT` 角色自动授权新按钮(仿 plan-archive 脚本)
---
### Task 17: 验证与图谱
- [ ] **Step 1:** 后端 smoke
Run: `cd backend && python3 -c "from app.main import app; print('backend ok')"`
- [ ] **Step 2:** 前端 lint(改动文件)
Run: `cd web-admin && pnpm exec eslint src/views/pl/freight src/components/business/pl-freight-* --max-warnings 0`
- [ ] **Step 3:** 更新图谱
Run: `./scripts/update-graph.sh all`
- [ ] **Step 4:** 手工验收 spec V1–V13
---
## Spec 覆盖自检
| Spec 章节 | Task |
|-----------|------|
| 四 Tab D1 | Task 10–15 |
| 审批 D2 | Task 5, 8, 13 |
| 财务 Tab D3 | Task 14 |
| 计划展示/运输落库 D4 | Task 4, 11 |
| 未结算准入 D5 | Task 4, 11 |
| 状态机 §6 | Task 1, 5, 6, 7 |
| 权限 §1.3 | Task 1, 16 |
| 验收 V1–V13 | Task 17 |
---
## 执行顺序建议
```
Task 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 (后端闭环)
Task 9 → 10 → 11 → 12 → 13 → 14 → 15 (前端)
Task 16 → 17 (权限 + 验收)
```
Phase 可分批合并 PR:P1=Task1-8+9-11,P2=12-13,P3=14-17。