Files
shanxia2.0/docs/superpowers/plans/2026-06-25-pl-freight-settlement.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: 将运费结算重构为四 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.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。