Explorar o código

feat: add vehicle recharge approval pending approve reject disburse

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 1 mes
pai
achega
f80a29fb8d

+ 2 - 0
backend/app/api/pl/__init__.py

@@ -6,6 +6,7 @@ from app.api.pl import (
     base_data,
     vehicle_fuel,
     vehicle_recharge,
+    vehicle_recharge_approval,
     vehicle_maintenance,
     vehicle_urea,
     transport_address,
@@ -55,6 +56,7 @@ pl_router.include_router(menu_badges.router)
 # ETC/加油充值
 pl_router.include_router(vehicle_recharge.apply_router)
 pl_router.include_router(vehicle_recharge.record_router)
+pl_router.include_router(vehicle_recharge_approval.approval_router)
 
 pl_router.include_router(vehicle_fuel.router)
 

+ 90 - 0
backend/app/api/pl/vehicle_recharge_approval.py

@@ -0,0 +1,90 @@
+"""车辆充值审批 API"""
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.db_auth import DbUserContext, get_db_current_user
+from app.core.tenant_context import get_tenant_no
+from app.schemas.pl.vehicle_recharge import (
+    VehicleRechargeApplyListResponse,
+    VehicleRechargeApplyRead,
+    VehicleRechargeOpinionBody,
+    VehicleRechargeRecordCreate,
+)
+from app.services import pl_vehicle_recharge_approval_service as approval_svc
+
+approval_router = APIRouter(
+    prefix="/vehicle-recharge-approval", tags=["运输计划-ETC加油充值审批"]
+)
+
+
+@approval_router.get("/pending", response_model=VehicleRechargeApplyListResponse)
+def list_pending(
+    page: int = Query(default=1, ge=1),
+    page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> VehicleRechargeApplyListResponse:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    items, total = approval_svc.list_pending(
+        db, tenant_no, user, page=page, page_size=page_size
+    )
+    return VehicleRechargeApplyListResponse(
+        items=items, total=total, page=page, pageSize=page_size
+    )
+
+
+@approval_router.get("/done", response_model=VehicleRechargeApplyListResponse)
+def list_done(
+    page: int = Query(default=1, ge=1),
+    page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> VehicleRechargeApplyListResponse:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    items, total = approval_svc.list_done(
+        db, tenant_no, user, page=page, page_size=page_size
+    )
+    return VehicleRechargeApplyListResponse(
+        items=items, total=total, page=page, pageSize=page_size
+    )
+
+
+@approval_router.post("/{apply_id}/approve", response_model=VehicleRechargeApplyRead)
+def approve_apply(
+    apply_id: str,
+    body: Optional[VehicleRechargeOpinionBody] = None,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> VehicleRechargeApplyRead:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    opinion = body.opinion if body else None
+    return approval_svc.approve_apply(
+        db, tenant_no, user, apply_id, opinion=opinion
+    )
+
+
+@approval_router.post("/{apply_id}/reject", response_model=VehicleRechargeApplyRead)
+def reject_apply(
+    apply_id: str,
+    body: VehicleRechargeOpinionBody,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> VehicleRechargeApplyRead:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    return approval_svc.reject_apply(
+        db, tenant_no, user, apply_id, opinion=body.opinion
+    )
+
+
+@approval_router.post("/{apply_id}/disburse", response_model=VehicleRechargeApplyRead)
+def disburse_apply(
+    apply_id: str,
+    payload: VehicleRechargeRecordCreate,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> VehicleRechargeApplyRead:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    return approval_svc.disburse_apply(db, tenant_no, user, apply_id, payload)

+ 315 - 0
backend/app/services/pl_vehicle_recharge_approval_service.py

@@ -0,0 +1,315 @@
+"""车辆充值审批:待办、通过、驳回、发放登记。"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.db_auth import DbUserContext
+from app.core.utils import generate_uuid
+from app.models.bx_models import BxWorkflowNode
+from app.models.pl_models import (
+    PlVehicleRechargeApply,
+    PlVehicleRechargeApproval,
+    PlVehicleRechargeRecord,
+)
+from app.schemas.pl.vehicle_recharge import (
+    VehicleRechargeApplyRead,
+    VehicleRechargeRecordCreate,
+)
+from app.services.pl_vehicle_recharge_service import (
+    apply_to_read,
+    generate_recharge_order_no,
+    _workflow_nodes,
+)
+
+
+def _get_apply_for_approval(
+    db: Session, tenant_no: str, apply_id: str
+) -> PlVehicleRechargeApply:
+    row = (
+        db.query(PlVehicleRechargeApply)
+        .filter(
+            PlVehicleRechargeApply.id == apply_id,
+            PlVehicleRechargeApply.tenant_no == tenant_no,
+            PlVehicleRechargeApply.del_flag == 0,
+        )
+        .first()
+    )
+    if not row:
+        raise HTTPException(status_code=404, detail="申请单不存在")
+    return row
+
+
+def _current_node(
+    db: Session,
+    apply: PlVehicleRechargeApply,
+    user: DbUserContext,
+    tenant_no: str,
+) -> BxWorkflowNode:
+    if apply.status != "in_approval" or not apply.current_node:
+        raise HTTPException(status_code=400, detail="申请单不在审批中")
+    node = (
+        db.query(BxWorkflowNode)
+        .filter(
+            BxWorkflowNode.id == apply.current_node,
+            BxWorkflowNode.tenant_no == tenant_no,
+            BxWorkflowNode.del_flag == 0,
+        )
+        .first()
+    )
+    if not node:
+        raise HTTPException(status_code=400, detail="当前审批节点不存在或已删除")
+    if node.approver_id != user.id:
+        raise HTTPException(status_code=403, detail="无权审批当前节点")
+    return node
+
+
+def _append_approval(
+    db: Session,
+    *,
+    apply: PlVehicleRechargeApply,
+    node: BxWorkflowNode,
+    user: DbUserContext,
+    tenant_no: str,
+    status: str,
+    opinion: Optional[str],
+    now: datetime,
+) -> PlVehicleRechargeApproval:
+    row = PlVehicleRechargeApproval(
+        id=generate_uuid(),
+        apply_id=apply.id,
+        approval_round=apply.approval_round or 1,
+        node_id=node.id,
+        node_name=node.node_name,
+        approver_id=user.id,
+        approver_name=user.name or "",
+        approval_status=status,
+        approval_opinion=(opinion or "").strip() or None,
+        approval_time=now,
+        tenant_no=tenant_no,
+        create_by=user.id,
+        create_dept_id=user.dept_id or "",
+        create_time=now,
+        update_by=user.id,
+        update_time=now,
+        del_flag=0,
+    )
+    db.add(row)
+    return row
+
+
+def list_pending(
+    db: Session,
+    tenant_no: str,
+    user: DbUserContext,
+    *,
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[VehicleRechargeApplyRead], int]:
+    query = (
+        db.query(PlVehicleRechargeApply)
+        .join(
+            BxWorkflowNode,
+            BxWorkflowNode.id == PlVehicleRechargeApply.current_node,
+        )
+        .filter(
+            PlVehicleRechargeApply.tenant_no == tenant_no,
+            PlVehicleRechargeApply.del_flag == 0,
+            PlVehicleRechargeApply.status == "in_approval",
+            BxWorkflowNode.tenant_no == tenant_no,
+            BxWorkflowNode.del_flag == 0,
+            BxWorkflowNode.approver_id == user.id,
+        )
+    )
+    # 待办按当前节点审批人授权,不做 create_by 数据范围过滤
+    total = query.count()
+    page = max(1, page)
+    page_size = min(max(1, page_size), 500)
+    rows = (
+        query.order_by(PlVehicleRechargeApply.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [apply_to_read(row, db, include_detail=True) for row in rows], total
+
+
+def list_done(
+    db: Session,
+    tenant_no: str,
+    user: DbUserContext,
+    *,
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[VehicleRechargeApplyRead], int]:
+    apply_ids = [
+        r[0]
+        for r in (
+            db.query(PlVehicleRechargeApproval.apply_id)
+            .filter(
+                PlVehicleRechargeApproval.tenant_no == tenant_no,
+                PlVehicleRechargeApproval.del_flag == 0,
+                PlVehicleRechargeApproval.approver_id == user.id,
+            )
+            .distinct()
+            .all()
+        )
+    ]
+    if not apply_ids:
+        return [], 0
+    query = db.query(PlVehicleRechargeApply).filter(
+        PlVehicleRechargeApply.tenant_no == tenant_no,
+        PlVehicleRechargeApply.del_flag == 0,
+        PlVehicleRechargeApply.id.in_(apply_ids),
+    )
+    total = query.count()
+    page = max(1, page)
+    page_size = min(max(1, page_size), 500)
+    rows = (
+        query.order_by(PlVehicleRechargeApply.update_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [apply_to_read(row, db, include_detail=True) for row in rows], total
+
+
+def approve_apply(
+    db: Session,
+    tenant_no: str,
+    user: DbUserContext,
+    apply_id: str,
+    *,
+    opinion: Optional[str] = None,
+) -> VehicleRechargeApplyRead:
+    apply = _get_apply_for_approval(db, tenant_no, apply_id)
+    node = _current_node(db, apply, user, tenant_no)
+    action = (node.action_type or "approve").strip() or "approve"
+    if action != "approve":
+        raise HTTPException(status_code=400, detail="当前节点请使用发放登记")
+    nodes = _workflow_nodes(db, apply.wf_id, tenant_no)
+    idx = next((i for i, n in enumerate(nodes) if n.id == node.id), None)
+    if idx is None:
+        raise HTTPException(status_code=400, detail="当前审批节点不在流程中")
+    if idx >= len(nodes) - 1:
+        raise HTTPException(status_code=400, detail="末节点须为发放登记,不能仅通过")
+    now = datetime.now()
+    _append_approval(
+        db,
+        apply=apply,
+        node=node,
+        user=user,
+        tenant_no=tenant_no,
+        status="approved",
+        opinion=opinion,
+        now=now,
+    )
+    apply.current_node = nodes[idx + 1].id
+    apply.update_by = user.id
+    apply.update_time = now
+    db.commit()
+    db.refresh(apply)
+    return apply_to_read(apply, db, include_detail=True)
+
+
+def reject_apply(
+    db: Session,
+    tenant_no: str,
+    user: DbUserContext,
+    apply_id: str,
+    *,
+    opinion: Optional[str] = None,
+) -> VehicleRechargeApplyRead:
+    apply = _get_apply_for_approval(db, tenant_no, apply_id)
+    node = _current_node(db, apply, user, tenant_no)
+    opinion_text = (opinion or "").strip()
+    if not opinion_text:
+        raise HTTPException(status_code=400, detail="驳回须填写意见")
+    now = datetime.now()
+    _append_approval(
+        db,
+        apply=apply,
+        node=node,
+        user=user,
+        tenant_no=tenant_no,
+        status="rejected",
+        opinion=opinion_text,
+        now=now,
+    )
+    apply.status = "rejected"
+    apply.current_node = None
+    apply.update_by = user.id
+    apply.update_time = now
+    db.commit()
+    db.refresh(apply)
+    return apply_to_read(apply, db, include_detail=True)
+
+
+def disburse_apply(
+    db: Session,
+    tenant_no: str,
+    user: DbUserContext,
+    apply_id: str,
+    payload: VehicleRechargeRecordCreate,
+) -> VehicleRechargeApplyRead:
+    apply = _get_apply_for_approval(db, tenant_no, apply_id)
+    node = _current_node(db, apply, user, tenant_no)
+    action = (node.action_type or "approve").strip() or "approve"
+    if action != "disburse":
+        raise HTTPException(status_code=400, detail="当前节点不是发放节点")
+    if payload.recharge_type != apply.recharge_type:
+        raise HTTPException(status_code=400, detail="充值类型与申请单不一致")
+    existing = (
+        db.query(PlVehicleRechargeRecord.id)
+        .filter(
+            PlVehicleRechargeRecord.apply_id == apply.id,
+            PlVehicleRechargeRecord.tenant_no == tenant_no,
+            PlVehicleRechargeRecord.del_flag == 0,
+        )
+        .first()
+    )
+    if existing:
+        raise HTTPException(status_code=400, detail="该申请单已有发放记录")
+
+    now = datetime.now()
+    record = PlVehicleRechargeRecord(
+        id=generate_uuid(),
+        recharge_order_no=generate_recharge_order_no(db),
+        recharge_date=payload.recharge_date,
+        recharge_type=apply.recharge_type,
+        card_no=(payload.card_no or "").strip() or None,
+        gas_station=(payload.gas_station or "").strip() or None,
+        amount=payload.amount,
+        attachment_urls=payload.attachment_urls,
+        apply_id=apply.id,
+        license_plate=apply.license_plate,
+        tenant_no=tenant_no,
+        remark=payload.remark,
+        create_by=user.id,
+        create_dept_id=user.dept_id or "",
+        create_time=now,
+        update_by=user.id,
+        update_time=now,
+        del_flag=0,
+    )
+    db.add(record)
+    _append_approval(
+        db,
+        apply=apply,
+        node=node,
+        user=user,
+        tenant_no=tenant_no,
+        status="disbursed",
+        opinion=payload.remark,
+        now=now,
+    )
+    apply.status = "disbursed"
+    apply.current_node = None
+    apply.update_by = user.id
+    apply.update_time = now
+    db.commit()
+    db.refresh(apply)
+    return apply_to_read(apply, db, include_detail=True)

+ 0 - 157
backend/graphify-out/.graphify_labels.json

@@ -137,10 +137,7 @@
   "135": "context.py",
   "136": "get_db_current_user_optional",
   "137": "iter_user_menu_perm_codes",
-  "138": "Community 138",
-  "139": "customer_options",
   "140": "crm_export_columns.py",
-  "141": "web_login_rsa_public_key",
   "142": "clear_pl_plan_instance_data.py",
   "143": "create_from_quote",
   "144": "response.py",
@@ -150,7 +147,6 @@
   "148": "plan_has_incomplete_hazard_cert_exists",
   "149": ".to_tenant_user_dict",
   "150": "list_product_options",
-  "151": "Community 151",
   "152": "register_local_callback",
   "153": "web_login_rsa_public_key",
   "154": "redis_client.py",
@@ -176,20 +172,15 @@
   "174": "test_pl_vehicle_ops_list_filters.py",
   "175": "pl_ocr_service.py",
   "176": "invoice_list_filters.py",
-  "177": "build_unsettled_plan_column_filters",
   "178": "build_contract_template_column_filters",
   "179": "test_pl_vehicle_personnel_photo_schema.py",
-  "180": "软删装货产品与装/送货段合同;送货段本体由 upsert 保留主键,不在此全量软删。",
-  "181": "PlCarrier",
   "182": "vehicle_recharge.py",
   "183": "resolve_name_pinyin_abbr",
   "184": "verify_plan_archive_zip_export.py",
   "185": "public_transport_site.py",
   "186": "BaseStorage",
   "187": "price_letter.py",
-  "188": "api_crypto.py",
   "189": "build_customer_column_filters",
-  "190": "Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}      流水与撞号检测须含软删:uk_contr",
   "191": "pl_plan_cert_perm.py",
   "192": "assert_row_deletable_by_creator",
   "193": "CRUDBase",
@@ -200,17 +191,13 @@
   "198": "login_crypto.py",
   "199": "WechatMessageService",
   "200": "quote_share_log.py",
-  "201": "clue.py",
   "202": "build_clue_column_filters",
   "203": "product_list_filters.py",
   "204": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
-  "205": "build_contract_change_column_filters",
   "206": "crm_quote_pdf.py",
   "207": "shanxia2.0 后端骨架(FastAPI)",
-  "208": "customer_follow.py",
   "209": "storage.py",
   "210": "customer_pool.py",
-  "211": "车辆ETC/加油充值申请 pl_vehicle_recharge_apply",
   "212": "contract_list_filters.py",
   "213": "web_auth_roles.py",
   "214": "timedelta",
@@ -219,24 +206,15 @@
   "217": "pl_plan_list_attachments.py",
   "218": "若存在入库/出库单,用明细 actual_qty 覆盖归档提交中的实际数量(权威来源)。",
   "219": "expense_item.py",
-  "220": "apply_site_feedback",
-  "221": "admin_request_json.py",
   "222": "start.sh",
   "223": "vehicle_recharge.py",
-  "224": "_history_matches_date",
   "225": "FakeRedis",
   "226": "invoice.py",
   "227": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
-  "228": "resolve_product_id_for_quote_item",
   "229": "Java: viewByShareCode",
-  "230": "草稿/已驳回结算单按当前运输单运费刷新明细快照与合计。",
-  "231": "build_advance_column_filters",
-  "232": "crm_export_columns.py",
   "233": "code:block1 (backend/)",
   "234": "calc_age_from_chinese_id_card",
   "235": "wx_qr_session.py",
-  "236": "一次上传一条发票:多文件挂同一档案,金额为总额,只写一条额度流水。      使用 request.form().getlist,兼容只传 1 个文件时 Fas",
-  "237": "order_status_compat.py",
   "238": "vehicle_urea.py",
   "239": "shanxia2.0 Backend 生产部署说明",
   "240": "reimburse.py",
@@ -257,19 +235,15 @@
   "255": "vehicle_fuel.py",
   "256": "Java: createFromOpportunity — 返回合同 id",
   "257": "mini_auth.py",
-  "258": "dict_data.py",
   "259": "dict_type.py",
   "260": "tenant.py",
-  "261": "手动触发计划按月自动生成(定时任务同款逻辑)。",
   "262": "Java Controller TODO — 保持 pending,记录备注",
   "263": "__init__.py",
-  "264": "__init__.py",
   "265": "供方全量列表(下拉)— Java: CrmSupplierInfoController.listAll",
   "266": "menu.py",
   "267": "post.py",
   "268": "leave.py",
   "269": "site.py",
-  "270": "validate_stop_products_package_against_contract",
   "271": "statistics.py",
   "272": "default_settlement_clause",
   "273": "storage.py",
@@ -277,34 +251,24 @@
   "275": "fix_pl_transport_db_link_stop_ids.py",
   "276": "供方详情 — Java: CrmSupplierInfoController.getInfo",
   "277": "新增供方 — Java: CrmSupplierInfoController.add",
-  "278": "修改供方 — Java: CrmSupplierInfoController.edit",
   "279": "修改供方 — Java: CrmSupplierInfoController.edit",
   "280": "resolve_wechat_login_user",
   "281": "apply_crm_owner_scope",
   "282": "crm_quote_pdf.py",
-  "283": "删除供方 — Java: CrmSupplierInfoController.remove",
-  "284": "导出供方列表 — Java: CrmSupplierInfoController.export",
   "285": "导出供方列表 — Java: CrmSupplierInfoController.export",
   "286": "shanxia2.0 后端骨架(FastAPI)",
   "287": "test_wx_qr_session.py",
-  "288": "按客户ID查询开票信息(与 by-customer 等价)",
-  "289": "Java: queryList for export",
   "290": "按入参整表覆盖合同扫描件;attachments=[] 表示清空。",
   "291": "Java: createFromQuote — 返回合同 id",
-  "292": "列表展示用:优先 CRM 标准名(详情仍返回库内 product_name,以便编辑反推别名)。",
-  "293": "导出出入库列表(当前筛选全量,或 ids 已选)。",
   "294": "证书 URL 为空(NULL / 空串 / 纯空白)。",
   "295": "导出出入库列表(当前筛选全量,或 ids 已选)。",
-  "296": "单条查询(三方 H5 填写页无需门户登录)。",
   "297": "返回 'archived' | 'arranged_unarchived' | None(不计入)。",
   "298": "collect_unique_delivery_date_strings",
   "299": "单条查询(三方 H5 填写页无需门户登录)。",
-  "300": "三方 H5 填写页初始化数据(无需门户登录,与原系统一致)。",
   "301": "price_letter.py",
   "302": "clear_pl_plan_instance_data.py",
   "303": "导出地磅单列表(当前筛选全量,或 ids 已选)。",
   "304": "__init__.py",
-  "305": "__init__.py",
   "306": "__init__.py",
   "307": "__init__.py",
   "308": "__init__.py",
@@ -317,28 +281,18 @@
   "315": "base_data.py",
   "316": "get_active",
   "317": "shanxia2.0 Backend 生产部署说明",
-  "318": "导出司机列表(当前筛选全量,或 ids 已选)。",
-  "319": "导出承运商列表(当前筛选全量,或 ids 已选)。",
-  "320": "导出车辆列表(当前筛选全量,或 ids 已选)。",
-  "321": "前端预览误用单位全称时,名称片段会等于面板/客户/开票全称。",
-  "322": "导出人员列表(当前筛选全量,或 ids 已选)。",
   "323": "customer_stop_pref.py",
   "324": "db_collation.py",
   "325": "__init__.py",
   "326": "customer_pool.py",
   "327": "io_order.py",
   "328": "_plan_plate_match",
-  "329": "save_driver_plan_feedback",
-  "330": "将关联且状态为 approved 的合同置为 executing(不 commit)。返回已推进的合同 id。",
   "331": "common.py",
   "332": "_plan_product_name_exists",
   "333": "创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)",
-  "334": "车辆尿素申请 pl_vehicle_urea_apply",
-  "335": "公开分享查看报价单 — Java: GET /share/{shareCode}(无 JWT)",
   "336": "创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)",
   "337": "按客户ID查询开票信息(与 by-customer 等价)",
   "338": "创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)",
-  "339": "新增供方 — Java: CrmSupplierInfoController.add",
   "340": "返回全部已完成的审批动作(含撤回/重提前的历史轮次),按时间正序。",
   "341": "已有汇款凭证但结算单仍停留在 approved 时自动纠正(兼容历史脏数据)",
   "342": "软删除结算单历史审批记录(重新提交审批前清理)",
@@ -356,7 +310,6 @@
   "354": "__init__.py",
   "355": "__init__.py",
   "356": "__init__.py",
-  "357": "创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)",
   "358": "创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)",
   "359": "创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)",
   "360": "列表展示用:优先 CRM 标准名(详情仍返回库内 product_name,以便编辑反推别名)。",
@@ -380,25 +333,18 @@
   "378": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
   "379": "生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)",
   "380": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
-  "381": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
   "382": "__init__.py",
   "383": "__init__.py",
   "384": "__init__.py",
   "385": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
-  "386": "同一节点多次审批时,取 approval_time 最新的一条作为当前节点状态。",
   "387": "返回全部审批动作,按时间正序排列,用于展示完整审批历史。",
   "388": "同一节点多次审批时,取 approval_time 最新的一条作为当前节点状态。",
   "389": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
   "390": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
   "391": "生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)",
   "392": "返回全部审批动作,按时间正序排列,用于展示完整审批历史。",
-  "393": "生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)",
-  "394": "返回全部审批动作,按时间正序排列,用于展示完整审批历史。",
   "395": "生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)",
   "396": "生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)",
-  "397": "归一化勾选链接;返回 None 表示未传(不改关联),[] 表示清空。",
-  "398": "同一节点多次审批时,取 approval_time 最新的一条作为当前节点状态。",
-  "399": "返回全部审批动作,按时间正序排列,用于展示完整审批历史。",
   "400": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
   "401": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
   "402": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
@@ -417,7 +363,6 @@
   "415": "__init__.py",
   "416": "__init__.py",
   "417": "更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)",
-  "418": "获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。",
   "419": "获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。",
   "420": "获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。",
   "421": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
@@ -426,7 +371,6 @@
   "424": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
   "425": "作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。",
   "426": "获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。",
-  "427": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
   "428": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
   "429": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
   "430": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
@@ -452,20 +396,16 @@
   "450": "作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。",
   "451": "将历史分装单号统一为 NO:SXFZYYYYMMDD-###",
   "452": "强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。",
-  "453": "将历史分装单号统一为 NO:SXFZYYYYMMDD-###",
   "454": "将历史分装单号统一为 NO:SXFZYYYYMMDD-###",
-  "455": "作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。",
   "456": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
   "457": "分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)",
   "458": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
   "459": "分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)",
   "460": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
-  "461": "分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)",
   "462": "分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)",
   "463": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
   "464": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
   "465": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
-  "466": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
   "467": "按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。",
   "468": "分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)",
   "469": "分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)",
@@ -484,14 +424,12 @@
   "482": "导出计划列表(当前筛选全量,或 ids 已选)。",
   "483": "导出计划列表(当前筛选全量,或 ids 已选)。",
   "484": "导出计划列表(当前筛选全量,或 ids 已选)。",
-  "485": "导出计划列表(当前筛选全量,或 ids 已选)。",
   "486": "导出计划列表(当前筛选全量,或 ids 已选)。",
   "487": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
   "488": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
   "489": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
   "490": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
   "491": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
-  "492": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
   "493": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
   "494": "表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。",
   "495": "code:bash (cd /path/to/shanxia2.0)",
@@ -507,23 +445,16 @@
   "505": "_format_plan_date",
   "506": "_resolve_plan_flags",
   "507": "transport.py",
-  "508": "导出运输列表(当前筛选全量,或 ids 已选)。",
   "509": "_transport_has_db_links",
-  "510": "导出维保申请列表(当前筛选全量,或 ids 已选)。",
   "511": "vehicle_urea.py",
   "512": "_avatar_url_field",
   "513": "_build_user_info_payload",
   "514": "_jwt_user_payload",
-  "515": "登出:吊销 Redis 会话(若已配置 REDIS_HOST)。无 Token 或已失效时仍返回成功(幂等)。",
-  "516": "获取当前用户信息(与 sys_user 字段对应)。",
   "517": "获取当前用户信息(与 sys_user 字段对应)。",
-  "518": "小程序内嵌 WebView:用 ticket 兑换 Web JWT(不吊销原 refresh)。",
   "519": "公开分享查看报价单 — Java: GET /share/{shareCode}(无 JWT)",
   "520": "与 web-admin 静态路由 meta.roles(admin/super_admin)对齐的单元素数组。",
   "521": "与 web-admin `userInfo` 字段约定一致。",
-  "522": "Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。",
   "523": "Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)",
-  "524": "Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)",
   "525": "Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。",
   "526": "_web_role_list",
   "527": "auth.py",
@@ -547,11 +478,8 @@
   "545": "框架合同提交审批校验:仅要求有产品明细;调价区间为可选,不强制。",
   "546": "框架合同提交审批校验:仅要求有产品明细;调价区间为可选,不强制。",
   "547": "Java: deleteWithValidByIds — 软删除",
-  "548": "Java: deleteWithValidByIds — 软删除",
   "549": "框架合同提交审批校验:仅要求有产品明细;调价区间为可选,不强制。",
-  "550": "框架合同提交审批校验:仅要求有产品明细;调价区间为可选,不强制。",
   "551": "Java: batchSubmitApproval",
-  "552": "Java: withdrawApproval",
   "553": "Java: batchSubmitApproval",
   "554": "Java: batchSubmitApproval",
   "555": "Java: batchSubmitApproval",
@@ -559,16 +487,13 @@
   "557": "Java: withdrawApproval",
   "558": "Java: withdrawApproval",
   "559": "Java: batchSubmitApproval",
-  "560": "Java: batchSubmitApproval",
   "561": "Java: withdrawApproval",
   "562": "按入参整表覆盖合同扫描件;attachments=[] 表示清空。",
   "563": "按入参整表覆盖合同扫描件;attachments=[] 表示清空。",
   "564": "按入参整表覆盖合同扫描件;attachments=[] 表示清空。",
-  "565": "按入参整表覆盖合同扫描件;attachments=[] 表示清空。",
   "566": "按入参整表覆盖合同扫描件;attachments=[] 表示清空。",
   "567": "按入参整表覆盖合同扫描件;attachments=[] 表示清空。",
   "568": "Java: createFromQuote — 返回合同 id",
-  "569": "Java: createFromQuote — 返回合同 id",
   "570": "Java: createFromQuote — 返回合同 id",
   "571": "Java: createFromQuote — 返回合同 id",
   "572": "Java: createFromQuote — 返回合同 id",
@@ -579,7 +504,6 @@
   "577": "Java: renewContract — 基础续签",
   "578": "Java: renewContract — 基础续签",
   "579": "Java: queryList for export",
-  "580": "Java: renewContract — 基础续签",
   "581": "Java: renewContract — 基础续签",
   "582": "Java: queryList for export",
   "583": "Java: queryList for export",
@@ -594,15 +518,12 @@
   "592": "Java: createFromOpportunity — 返回合同 id",
   "593": "Java: batchAssignOwner",
   "594": "Java: createFromOpportunity — 返回合同 id",
-  "595": "Java: createFromOpportunity — 返回合同 id",
   "596": "Java: downloadContractPdf — 返回合同文件 URL/路径",
   "597": "Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}",
-  "598": "Java: batchAssignOwner",
   "599": "Java: batchAssignOwner",
   "600": "Java: batchAssignOwner",
   "601": "Java: batchAssignOwner",
   "602": "Java: batchAssignOwner",
-  "603": "Java: downloadContractPdf — 返回合同文件 URL/路径",
   "604": "Java: downloadContractPdf — 返回合同文件 URL/路径",
   "605": "Java: downloadContractPdf — 返回合同文件 URL/路径",
   "606": "Java: batchAssignOwner",
@@ -613,47 +534,38 @@
   "611": "Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}",
   "612": "Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}",
   "613": "Java: downloadContractPdf — 返回合同文件 URL/路径",
-  "614": "Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}",
   "615": "Java: generateTemplateNo — TMPL{yyyyMMdd}{4位序号}",
   "616": "Java Controller TODO — 保持 pending,记录备注",
   "617": "Java Controller TODO — 保持 pending,记录备注",
-  "618": "Java Controller TODO — 保持 pending,记录备注",
   "619": "Java Controller TODO — 保持 pending,记录备注",
   "620": "Java Controller TODO — 保持 pending,记录备注",
   "621": "Java Controller TODO — 保持 pending,记录备注",
   "622": "Java Controller TODO — 保持 pending,记录备注",
   "623": "Java Controller TODO — 保持 pending,记录备注",
-  "624": "根据供需方面板与主数据生成/校正合同名称。",
   "625": "根据供需方面板与主数据生成/校正合同名称。",
   "626": "根据供需方面板与主数据生成/校正合同名称。",
   "627": "Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}      流水与撞号检测须含软删:uk_contr",
   "628": "Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}      流水与撞号检测须含软删:uk_contr",
   "629": "Java: generateContractNo — {供方编码}/X-JT{yyyyMMdd}{3位序号}      流水与撞号检测须含软删:uk_contr",
-  "630": "租户内合同编号是否已占用(含软删,保证全局唯一)。",
   "631": "租户内合同编号是否已占用(含软删,保证全局唯一)。",
   "632": "租户内合同编号是否已占用(含软删,保证全局唯一)。",
   "633": "标准编号替换日期段,保留序号;非标准返回 None。",
   "634": "租户内合同编号是否已占用(含软删,保证全局唯一)。",
   "635": "标准编号替换日期段,保留序号;非标准返回 None。",
-  "636": "标准编号替换日期段,保留序号;非标准返回 None。",
-  "637": "将标准合同编号日期段对齐签订日;撞号则序号顺延(含软删查重)。      非标准编号原样返回。无签订日则原样返回。",
   "638": "将标准合同编号日期段对齐签订日;撞号则序号顺延(含软删查重)。      非标准编号原样返回。无签订日则原样返回。",
   "639": "将标准合同编号日期段对齐签订日;撞号则序号顺延(含软删查重)。      非标准编号原样返回。无签订日则原样返回。",
   "640": "按面板单位全称精确匹配交易主体/客户主数据,取 shortName;对不上则用全称。",
-  "641": "按面板单位全称精确匹配交易主体/客户主数据,取 shortName;对不上则用全称。",
   "642": "按面板单位全称精确匹配交易主体/客户主数据,取 shortName;对不上则用全称。",
   "643": "按面板单位全称精确匹配交易主体/客户主数据,取 shortName;对不上则用全称。",
   "644": "前端预览误用单位全称时,名称片段会等于面板/客户/开票全称。",
   "645": "按面板供方(partyA)-需方(partyB)生成:{供方简称}-{需方简称}({合同编号})。      - 名称为空:按主数据简称自动生成     - 名",
   "646": "前端预览误用单位全称时,名称片段会等于面板/客户/开票全称。",
   "647": "前端预览误用单位全称时,名称片段会等于面板/客户/开票全称。",
-  "648": "按面板供方(partyA)-需方(partyB)生成:{供方简称}-{需方简称}({合同编号})。      - 名称为空:按主数据简称自动生成     - 名",
   "649": "按面板供方(partyA)-需方(partyB)生成:{供方简称}-{需方简称}({合同编号})。      - 名称为空:按主数据简称自动生成     - 名",
   "650": "按面板供方(partyA)-需方(partyB)生成:{供方简称}-{需方简称}({合同编号})。      - 名称为空:按主数据简称自动生成     - 名",
   "651": "customer_follow.py",
   "652": "customer_pool.py",
   "653": "customer.py",
-  "654": "客户下拉选项:租户内只读搜索,不做 owner 数据权限(供地磅/合同等主数据选用)。",
   "655": "invoice_info.py",
   "656": "pool_rule.py",
   "657": "price_letter.py",
@@ -662,25 +574,13 @@
   "660": "报价自动建档时生成产品编号(与产品管理新增规则一致)",
   "661": "Java: resolveProductIdForQuoteItem(CrmProduct 无 tax_rate,跳过税率匹配)",
   "662": "quote_approver_setting.py",
-  "663": "Java: viewByShareCode",
   "664": "Java: toContract — 返回 contractId",
-  "665": "Java: toContract — 返回 contractId",
-  "666": "Java: createNewVersion — 返回新报价单 id",
   "667": "Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人)",
-  "668": "Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人)",
   "669": "Java: statisticsOverview",
-  "670": "Java: statisticsOverview",
   "671": "Java: statisticsStatus",
-  "672": "Java: statisticsStatus",
   "673": "Java: statisticsTrend",
-  "674": "Java: statisticsTrend",
-  "675": "Java: fillContractLinkedCount",
-  "676": "Java: calculateAmount",
-  "677": "Java: resolveQuoteProductIds",
-  "678": "Java: generateQuoteNo — {供方编码}DD-yyyyMMdd + 3位序号",
   "679": "Java: generateQuoteNo — {供方编码}DD-yyyyMMdd + 3位序号",
   "680": "Java: deleteWithValidByIds",
-  "681": "Java: deleteWithValidByIds",
   "682": "statistics.py",
   "683": "supplier_info.py",
   "684": "__init__.py",
@@ -690,16 +590,11 @@
   "688": "__tablename__",
   "689": "__init__.py",
   "690": "车辆维保申请 pl_vehicle_maintenance_apply",
-  "691": "车辆维保处理登记 pl_vehicle_maintenance_process",
-  "692": "车辆维保申请 pl_vehicle_maintenance_apply",
   "693": "车辆尿素申请 pl_vehicle_urea_apply",
   "694": "车辆维保处理登记 pl_vehicle_maintenance_process",
   "695": "阿里云 OCR 月度调用计数 pl_ocr_usage_month(按自然月全局统计)",
-  "696": "车辆尿素发放登记 pl_vehicle_urea_disburse",
   "697": "车辆尿素发放登记 pl_vehicle_urea_disburse",
-  "698": "阿里云 OCR 月度调用计数 pl_ocr_usage_month(按自然月全局统计)",
   "699": "车辆ETC/加油充值发放登记 pl_vehicle_recharge_record",
-  "700": "车辆ETC/加油充值发放登记 pl_vehicle_recharge_record",
   "701": "attachment.py",
   "702": "auth.py",
   "703": "advance.py",
@@ -722,19 +617,12 @@
   "720": "contract_approver_setting.py",
   "721": "contract.py",
   "722": "POST /contract/approval — 按合同 ID 审批",
-  "723": "POST /contract/approval — 按合同 ID 审批",
-  "724": "POST /contract/export 筛选条件(无分页或沿用 list 参数)",
   "725": "POST /contract/export 筛选条件(无分页或沿用 list 参数)",
   "726": "续签 — 字段同创建,parent 由路径提供",
-  "727": "续签 — 字段同创建,parent 由路径提供",
-  "728": "GET /contract/approval/list;extra=allow 透传列头漏斗动态参数。",
   "729": "GET /contract/change/list;extra=allow 透传列头漏斗动态参数。",
-  "730": "GET /contract/change/list;extra=allow 透传列头漏斗动态参数。",
-  "731": "GET /contract/template/list;extra=allow 透传列头漏斗动态参数。",
   "732": "customer_follow.py",
   "733": "_forbid_legacy_invoice_fields",
   "734": "customer.py",
-  "735": "解析列表查询参数,供 API 与 CRUD 共用。",
   "736": "invoice_info.py",
   "737": "opportunity.py",
   "738": "pool_rule.py",
@@ -760,7 +648,6 @@
   "758": "io_order.py",
   "759": "plan.py",
   "760": "运维纠错:强制改跟进状态(仅改字段,不联动业务)。",
-  "761": "运维纠错:强制改跟进状态(仅改字段,不联动业务)。",
   "762": "_coerce_resource_snapshot",
   "763": "transport_address.py",
   "764": "validate_coordinates",
@@ -837,8 +724,6 @@
   "835": "_lookup_user_name",
   "836": "out_rows_with_attachments",
   "837": "invoice.py",
-  "838": "返回流水字典列表;流水备注为空时回填关联发票备注(含已软删发票)。",
-  "839": "一次上传创建一条发票档案:金额为总额,files 全部挂到同一 biz_id,只写一条 credit_upload。",
   "840": "_require_wallet_admin",
   "841": "_resolve_owner",
   "842": "_resolve_owner_for_query",
@@ -895,81 +780,41 @@
   "893": "list_sort.py",
   "894": "list_sort.py",
   "895": "plan_list_filters.py",
-  "896": "(product_id, package_spec) -> {archivedQty, arrangedUnarchivedQty, arrangedTotal",
-  "897": "plan_id -> {       load_ids: set[str] | None,   # None = 该侧全部装货段       delivery_",
   "898": "返回 productId -> 三数量字段。",
-  "899": "返回 (productId, packageSpec) -> 三数量字段。",
   "900": "按复合键匹配合同产品行;同键多行时 quantity 相加(防御)。",
-  "901": "已安排合计 − 本计划旧量 + 本计划新量。",
   "902": "将关联且状态为 approved 的合同置为 executing(不 commit)。返回已推进的合同 id。",
   "903": "已安排合计 − 本计划旧量 + 本计划新量。",
   "904": "返回:       - None: 跳过(本次无量)       - kind=violation | remaining | framework_cumula",
-  "905": "返回 'archived' | 'arranged_unarchived' | None(不计入)。",
-  "906": "返回:       - None: 跳过(本次无量)       - kind=violation | remaining | framework_cumula",
   "907": "product_id -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}",
-  "908": "返回 (violations, reminders)。     在写运输前调用;reminders 可缓存到成功响应,勿在状态变更后再算。",
   "909": "返回 (violations, reminders)。     在写运输前调用;reminders 可缓存到成功响应,勿在状态变更后再算。",
   "910": "超限则 422;通过则返回 reminders(供成功响应复用)。",
-  "911": "超限则 422;通过则返回 reminders(供成功响应复用)。",
   "912": "表单「可安排数量」与问号弹窗数据。     arrangedQty / availableQty 均不含 exclude_plan_id(若该计划已在已安排桶中",
-  "913": "表单「可安排数量」与问号弹窗数据。     arrangedQty / availableQty 均不含 exclude_plan_id(若该计划已在已安排桶中",
   "914": "勾选「生成地磅单」:按产品生成/复用地磅计划,并创建关联出入库单。",
   "915": "按段 upsert 地磅出入库与 link:新建 / 就地更新 / 取消勾选软删。      返回 (link_results, warnings)。已过磅取消",
   "916": "按装货/送货段勾选 upsert 地磅计划与出入库单(兼容旧调用方,忽略 warnings)。",
-  "917": "按装货/送货段勾选 upsert 地磅计划与出入库单(兼容旧调用方,忽略 warnings)。",
-  "918": "勾选「生成地磅单」:按产品生成/复用地磅计划,并创建关联出入库单。",
   "919": "勾选「生成地磅单」:按产品生成/复用地磅计划,并创建关联出入库单。",
   "920": "未过磅且状态可作废时,可就地更新日期/往来/车牌/数量。",
   "921": "计划保存后按当前段次/计划日期回写可编辑出入库单日期;跨月则改挂月计划。      不可回写时不抛错,返回 warnings。",
-  "922": "取消段 generate_dibang:已过磅阻断;否则软删 link+出入库并回减月计划。",
   "923": "就地更新已有出入库可变字段;不可编辑时写 warnings,不抛错。",
-  "924": "计划保存后按当前段次/计划日期回写可编辑出入库单日期;跨月则改挂月计划。      不可回写时不抛错,返回 warnings。",
   "925": "按装货/送货段勾选生成地磅计划与出入库单。",
-  "926": "按段产品 upsert 出入库+link:已有则就地更新,无则新建。",
-  "927": "运输单运费变更后,同步关联草稿结算单的明细快照与合计。",
   "928": "草稿/已驳回结算单按当前运输单运费刷新明细快照与合计。",
-  "929": "软删除结算单历史审批记录(重新提交审批前清理)",
-  "930": "审批记录已全部完成但结算单仍停留在 approving 时自动纠正(兼容历史脏数据)",
   "931": "审批记录已全部完成但结算单仍停留在 approving 时自动纠正(兼容历史脏数据)",
-  "932": "已有汇款凭证但结算单仍停留在 approved 时自动纠正(兼容历史脏数据)",
-  "933": "批量纠正已有汇款凭证但仍为 approved 的结算单",
-  "934": "返回全部已完成的审批动作(含撤回/重提前的历史轮次),按时间正序。",
   "935": "__init__.py",
   "936": "list_sort.py",
   "937": "无段表数据时回退计划产品级导出(兼容旧数据)。",
   "938": "构建归档导出数据行:每计划 × 每段次 × 每段产品一行。",
-  "939": "构建归档导出数据行:每计划 × 每段次 × 每段产品一行。      stop_kind 为 load / delivery 时只导出该类段次,与归档列表段次漏",
-  "940": "装货/送货段入库出库列方向(与前端 resolveArchiveListIoColumn 一致)。",
   "941": "单侧保存计划合同关联(旧计划级接口,兼容保留)。",
   "942": "单侧保存计划合同关联(旧计划级接口,兼容保留)。",
-  "943": "单侧保存计划合同关联(旧计划级接口,兼容保留)。",
-  "944": "按段次汇总计划是否仍有合同缺口(supplier=装货侧,demander=送货侧)。",
-  "945": "若存在入库/出库单,用明细 actual_qty 覆盖归档提交中的实际数量(权威来源)。",
-  "946": "一车多卸:段级数量已写入,此处仅处理计划产品品牌分量。",
-  "947": "解析司机:显式选择优先,否则取车辆关联司机;均无则允许为空。",
   "948": "解析司机:显式选择优先,否则取车辆关联司机;均无则允许为空。",
-  "949": "批量从 CRM 产品主数据读取易制毒/易制爆标识。",
   "950": "批量从 CRM 产品主数据读取易制毒/易制爆标识。",
   "951": "列表筛选:计划下存在 CRM 标识为易制毒/易制爆的产品。",
-  "952": "从 CRM 产品主数据读取易制毒/易制爆标识(不读计划产品快照)。",
   "953": "列表筛选:存在危化产品且至少一张必需证书未上传。",
-  "954": "列表筛选:计划下存在 CRM 标识为易制毒/易制爆的产品。",
-  "955": "证书 URL 为空(NULL / 空串 / 纯空白)。",
-  "956": "列表筛选:存在危化产品且至少一张必需证书未上传。",
   "957": "计划内所有危化品产品的所需证书槽位均已上传。",
-  "958": "计划内所有危化品产品的所需证书槽位均已上传。",
   "959": "全部危化品证书已上传时,自动标记证书齐备(与人工确认等效)。",
-  "960": "全部危化品证书已上传时,自动标记证书齐备(与人工确认等效)。",
-  "961": "写入/更新草稿运输单(pl_transport + pl_plan_transport),计划保持待安排。",
   "962": "写入/更新草稿运输单(pl_transport + pl_plan_transport),计划保持待安排。",
-  "963": "同步运输预选到草稿运输子表(pl_transport)。",
   "964": "同步运输预选到草稿运输子表(pl_transport)。",
-  "965": "装货段与 plan 1:1;uk_pl_plan_load_plan 仅按 plan_id 唯一,软删后不可再 INSERT,须原地更新。",
   "966": "装货段与 plan 1:1;uk_pl_plan_load_plan 仅按 plan_id 唯一,软删后不可再 INSERT,须原地更新。",
   "967": "_soft_delete_plan_stops",
-  "968": "保存段回传。成功返回 (True, None);失败返回 (False, msg)。不 commit。",
-  "969": "上传附件。成功返回 (data, None) 并 commit;失败返回 (None, (code, msg))。",
-  "970": "本租户计划 + 关联运输(须有有效 site_access_token)。找不到返回 (None, None)。",
   "971": "_resolve_plan_flags",
   "972": "_compare",
   "973": "_get_variable",
@@ -977,8 +822,6 @@
   "975": "_parse_variables",
   "976": "__init__.py",
   "977": "分配同租户、同报销日期下的标题序号(MAX+1)。     配合 uk_tenant_reimburse_date_title_seq 唯一约束,并发冲突时由上",
-  "978": "预览用标题生成(提交时仍会在服务端重新分配序号以保证并发安全)。",
-  "979": "确定最终标题与 title_seq。     - 未填标题或仍为自动标题格式时:按报销日期重新分配序号并生成标题。     - 自定义标题:title_seq",
   "980": "code:bash (cd /path/to/shanxia2.0)",
   "981": "code:bash (./start-prod.sh status    # 查看状态)",
   "982": "code:bash (curl -s http://127.0.0.1:9111/health)",

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 169 - 298
backend/graphify-out/GRAPH_REPORT.md


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
backend/graphify-out/cache/stat-index.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 168 - 168
backend/graphify-out/graph.json


+ 151 - 0
backend/tests/test_pl_vehicle_recharge_workflow.py

@@ -2,8 +2,10 @@
 from __future__ import annotations
 
 from datetime import date, datetime
+from decimal import Decimal
 from types import SimpleNamespace
 
+import pytest
 from sqlalchemy import create_engine
 from sqlalchemy.orm import sessionmaker
 
@@ -187,3 +189,152 @@ def test_resubmit_increments_round_keeps_history(monkeypatch):
     assert result.current_node == node_gm
     assert len(result.approvals) == 1
     assert result.approvals[0].approval_status == "rejected"
+
+
+def test_approve_advances_to_next_node(monkeypatch):
+    from app.services import pl_vehicle_recharge_approval_service as approval_svc
+    from app.services import pl_vehicle_recharge_service as svc
+
+    db = _make_session()
+    _seed_vehicle(db)
+    wf_id, node_gm, node_fin = _seed_workflow(db)
+    db.add(
+        PlVehicleRechargeApply(
+            id="apply-1",
+            apply_no="ETC2026092601",
+            license_plate="鄂A00001",
+            recharge_type="etc",
+            apply_date=date(2026, 9, 26),
+            applicant_id="u-applicant",
+            status="in_approval",
+            wf_id=wf_id,
+            current_node=node_gm,
+            approval_round=1,
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    db.commit()
+
+    _patch_scope(monkeypatch, svc)
+
+    result = approval_svc.approve_apply(
+        db, "t1", _user("u-gm", "总经理"), "apply-1", opinion="同意"
+    )
+    assert result.status == "in_approval"
+    assert result.current_node == node_fin
+    assert any(a.approval_status == "approved" for a in result.approvals)
+
+
+def test_reject_sets_rejected_and_appends_history(monkeypatch):
+    from app.services import pl_vehicle_recharge_approval_service as approval_svc
+    from app.services import pl_vehicle_recharge_service as svc
+
+    db = _make_session()
+    _seed_vehicle(db)
+    wf_id, node_gm, _node_fin = _seed_workflow(db)
+    db.add(
+        PlVehicleRechargeApply(
+            id="apply-1",
+            apply_no="ETC2026092601",
+            license_plate="鄂A00001",
+            recharge_type="etc",
+            apply_date=date(2026, 9, 26),
+            applicant_id="u-applicant",
+            status="in_approval",
+            wf_id=wf_id,
+            current_node=node_gm,
+            approval_round=1,
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    db.commit()
+
+    _patch_scope(monkeypatch, svc)
+
+    result = approval_svc.reject_apply(
+        db, "t1", _user("u-gm", "总经理"), "apply-1", opinion="材料不全"
+    )
+    assert result.status == "rejected"
+    assert result.current_node is None
+    assert result.approvals[-1].approval_status == "rejected"
+
+
+def test_disburse_writes_record_and_disbursed(monkeypatch):
+    from app.services import pl_vehicle_recharge_approval_service as approval_svc
+    from app.services import pl_vehicle_recharge_service as svc
+    from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
+
+    db = _make_session()
+    _seed_vehicle(db)
+    wf_id, _node_gm, node_fin = _seed_workflow(db)
+    db.add(
+        PlVehicleRechargeApply(
+            id="apply-1",
+            apply_no="ETC2026092601",
+            license_plate="鄂A00001",
+            recharge_type="etc",
+            apply_date=date(2026, 9, 26),
+            applicant_id="u-applicant",
+            status="in_approval",
+            wf_id=wf_id,
+            current_node=node_fin,
+            approval_round=1,
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    db.commit()
+
+    _patch_scope(monkeypatch, svc)
+
+    payload = VehicleRechargeRecordCreate(
+        rechargeDate=date(2026, 9, 26),
+        rechargeType="etc",
+        amount=Decimal("100.00"),
+        applyId="apply-1",
+    )
+    result = approval_svc.disburse_apply(
+        db, "t1", _user("u-fin", "财务"), "apply-1", payload
+    )
+    assert result.status == "disbursed"
+    assert result.record is not None
+    assert result.record.amount == Decimal("100.00")
+    assert any(a.approval_status == "disbursed" for a in result.approvals)
+
+
+def test_approve_forbidden_on_disburse_node(monkeypatch):
+    from fastapi import HTTPException
+
+    from app.services import pl_vehicle_recharge_approval_service as approval_svc
+    from app.services import pl_vehicle_recharge_service as svc
+
+    db = _make_session()
+    _seed_vehicle(db)
+    wf_id, _node_gm, node_fin = _seed_workflow(db)
+    db.add(
+        PlVehicleRechargeApply(
+            id="apply-1",
+            apply_no="ETC2026092601",
+            license_plate="鄂A00001",
+            recharge_type="etc",
+            apply_date=date(2026, 9, 26),
+            applicant_id="u-applicant",
+            status="in_approval",
+            wf_id=wf_id,
+            current_node=node_fin,
+            approval_round=1,
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    db.commit()
+
+    _patch_scope(monkeypatch, svc)
+
+    with pytest.raises(HTTPException) as exc:
+        approval_svc.approve_apply(
+            db, "t1", _user("u-fin", "财务"), "apply-1", opinion="ok"
+        )
+    assert exc.value.status_code == 400

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio