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: 将运费结算重构为四 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 脚本,含:
-- 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字典:
-- 新增/更新
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之前追加:
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:
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:
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:
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):
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):
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):
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:
_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 |
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 方法:
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 结构:
<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.vuesubset) -
Step 2: 表格 + selection + 工具栏「生成结算单」
type="primary"+ 已选 N 项 -
Step 3:
handleCreateSettlement→freightApi.create({ planIds })→ emitcreated -
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。