Просмотр исходного кода

feat: add vehicle recharge apply submit and detail approvals

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 месяц назад
Родитель
Сommit
ea8c2c0362

+ 10 - 0
backend/app/api/pl/vehicle_recharge.py

@@ -216,6 +216,16 @@ def get_apply(
     return svc.get_apply(db, tenant_no, user, id)
 
 
+@apply_router.post("/{id}/submit", response_model=VehicleRechargeApplyRead)
+def submit_apply(
+    id: str,
+    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 svc.submit_apply(db, tenant_no, user, id)
+
+
 @apply_router.post("/", response_model=VehicleRechargeApplyRead, status_code=status.HTTP_201_CREATED)
 def create_apply(
     payload: VehicleRechargeApplyCreate,

+ 171 - 11
backend/app/services/pl_vehicle_recharge_service.py

@@ -10,16 +10,19 @@ 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 BxWorkflowDef, BxWorkflowNode
 from app.models.models import SysUser
 from app.models.pl_models import (
     PlVehicle,
     PlVehicleRechargeApply,
+    PlVehicleRechargeApproval,
     PlVehicleRechargeRecord,
 )
 from app.schemas.pl.vehicle_recharge import (
     VehicleRechargeApplyCreate,
     VehicleRechargeApplyRead,
     VehicleRechargeApplyUpdate,
+    VehicleRechargeApprovalRead,
     VehicleRechargeDisburseBoardItem,
     VehicleRechargeRecordCreate,
     VehicleRechargeRecordRead,
@@ -27,6 +30,9 @@ from app.schemas.pl.vehicle_recharge import (
 )
 from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 
+_EDITABLE_STATUSES = frozenset({"draft", "rejected"})
+_BIZ_TYPE_VEHICLE_RECHARGE = "vehicle_recharge"
+
 
 def _generate_seq_no(
     db: Session, model, column_attr: str, prefix: str, *, seq_width: int = 6
@@ -157,7 +163,68 @@ def _has_active_record(db: Session, tenant_no: str, apply_id: str) -> bool:
     )
 
 
-def apply_to_read(row: PlVehicleRechargeApply, db: Session) -> VehicleRechargeApplyRead:
+def approval_to_read(row: PlVehicleRechargeApproval) -> VehicleRechargeApprovalRead:
+    return VehicleRechargeApprovalRead(
+        id=row.id,
+        applyId=row.apply_id,
+        approvalRound=row.approval_round,
+        nodeId=row.node_id,
+        nodeName=row.node_name,
+        approverId=row.approver_id,
+        approverName=row.approver_name,
+        approvalStatus=row.approval_status,
+        approvalOpinion=row.approval_opinion,
+        approvalTime=row.approval_time,
+    )
+
+
+def _list_approvals(
+    db: Session, tenant_no: str, apply_id: str
+) -> list[VehicleRechargeApprovalRead]:
+    rows = (
+        db.query(PlVehicleRechargeApproval)
+        .filter(
+            PlVehicleRechargeApproval.apply_id == apply_id,
+            PlVehicleRechargeApproval.tenant_no == tenant_no,
+            PlVehicleRechargeApproval.del_flag == 0,
+        )
+        .order_by(
+            PlVehicleRechargeApproval.approval_round.asc(),
+            PlVehicleRechargeApproval.approval_time.asc(),
+            PlVehicleRechargeApproval.create_time.asc(),
+        )
+        .all()
+    )
+    return [approval_to_read(row) for row in rows]
+
+
+def _get_linked_record(
+    db: Session, tenant_no: str, apply_id: str
+) -> Optional[PlVehicleRechargeRecord]:
+    return (
+        db.query(PlVehicleRechargeRecord)
+        .filter(
+            PlVehicleRechargeRecord.apply_id == apply_id,
+            PlVehicleRechargeRecord.tenant_no == tenant_no,
+            PlVehicleRechargeRecord.del_flag == 0,
+        )
+        .first()
+    )
+
+
+def apply_to_read(
+    row: PlVehicleRechargeApply,
+    db: Session,
+    *,
+    include_detail: bool = False,
+) -> VehicleRechargeApplyRead:
+    approvals: list[VehicleRechargeApprovalRead] = []
+    record_read: Optional[VehicleRechargeRecordRead] = None
+    if include_detail:
+        approvals = _list_approvals(db, row.tenant_no, row.id)
+        record_row = _get_linked_record(db, row.tenant_no, row.id)
+        if record_row:
+            record_read = record_to_read(record_row, db, apply_no=row.apply_no)
     return VehicleRechargeApplyRead(
         id=row.id,
         applyNo=row.apply_no,
@@ -169,12 +236,48 @@ def apply_to_read(row: PlVehicleRechargeApply, db: Session) -> VehicleRechargeAp
         applicantId=row.applicant_id,
         applicantName=_resolve_user_name(db, row.applicant_id),
         status=row.status,
+        wfId=row.wf_id,
+        currentNode=row.current_node,
+        approvalRound=row.approval_round or 0,
+        approvals=approvals,
+        record=record_read,
         remark=row.remark,
         createTime=row.create_time,
         updateTime=row.update_time,
     )
 
 
+def _workflow_nodes(db: Session, wf_id: str, tenant_no: str) -> list[BxWorkflowNode]:
+    return (
+        db.query(BxWorkflowNode)
+        .filter(
+            BxWorkflowNode.wf_id == wf_id,
+            BxWorkflowNode.tenant_no == tenant_no,
+            BxWorkflowNode.del_flag == 0,
+        )
+        .order_by(BxWorkflowNode.node_order.asc())
+        .all()
+    )
+
+
+def _get_vehicle_recharge_workflow(
+    db: Session, tenant_no: str
+) -> BxWorkflowDef:
+    workflow = (
+        db.query(BxWorkflowDef)
+        .filter(
+            BxWorkflowDef.biz_type == _BIZ_TYPE_VEHICLE_RECHARGE,
+            BxWorkflowDef.tenant_no == tenant_no,
+            BxWorkflowDef.del_flag == 0,
+            BxWorkflowDef.status == 1,
+        )
+        .first()
+    )
+    if not workflow:
+        raise HTTPException(status_code=400, detail="未配置启用中的充值审批流程")
+    return workflow
+
+
 def record_to_read(
     row: PlVehicleRechargeRecord, db: Session, apply_no: Optional[str] = None
 ) -> VehicleRechargeRecordRead:
@@ -221,6 +324,11 @@ def _resolve_apply_for_record(
 
     if payload.apply_id:
         apply = _get_apply_row(db, tenant_no, payload.apply_id, user)
+        if apply.status == "in_approval":
+            raise HTTPException(
+                status_code=400,
+                detail="审批中的申请请在「充值审批」中完成发放登记",
+            )
         if apply.status != "pending":
             raise HTTPException(status_code=400, detail="仅待发放申请可关联")
         existing = (
@@ -318,7 +426,7 @@ def get_apply(
     db: Session, tenant_no: str, user: DbUserContext, apply_id: str
 ) -> VehicleRechargeApplyRead:
     row = _get_apply_row(db, tenant_no, apply_id, user)
-    return apply_to_read(row, db)
+    return apply_to_read(row, db, include_detail=True)
 
 
 def create_apply(
@@ -340,7 +448,10 @@ def create_apply(
         odometer_km=payload.odometer_km,
         attachment_urls=payload.attachment_urls,
         applicant_id=applicant_id,
-        status="pending",
+        status="draft",
+        wf_id=None,
+        current_node=None,
+        approval_round=0,
         tenant_no=tenant_no,
         remark=payload.remark,
         create_by=user.id,
@@ -364,8 +475,8 @@ def update_apply(
     payload: VehicleRechargeApplyUpdate,
 ) -> VehicleRechargeApplyRead:
     row = _get_apply_row(db, tenant_no, apply_id, user)
-    if row.status != "pending":
-        raise HTTPException(status_code=400, detail="仅待发放申请可编辑")
+    if row.status not in _EDITABLE_STATUSES:
+        raise HTTPException(status_code=400, detail="仅草稿或已驳回申请可编辑")
     license_plate = payload.license_plate.strip()
     _assert_vehicle_exists(db, tenant_no, license_plate)
     row.license_plate = license_plate
@@ -386,8 +497,8 @@ def cancel_apply(
     db: Session, tenant_no: str, user: DbUserContext, apply_id: str
 ) -> VehicleRechargeApplyRead:
     row = _get_apply_row(db, tenant_no, apply_id, user)
-    if row.status != "pending":
-        raise HTTPException(status_code=400, detail="仅待发放申请可作废")
+    if row.status not in _EDITABLE_STATUSES:
+        raise HTTPException(status_code=400, detail="仅草稿或已驳回申请可作废")
     row.status = "cancelled"
     row.update_by = user.id
     row.update_time = datetime.now()
@@ -396,13 +507,36 @@ def cancel_apply(
     return apply_to_read(row, db)
 
 
+def submit_apply(
+    db: Session, tenant_no: str, user: DbUserContext, apply_id: str
+) -> VehicleRechargeApplyRead:
+    row = _get_apply_row(db, tenant_no, apply_id, user)
+    if row.status not in _EDITABLE_STATUSES:
+        raise HTTPException(status_code=400, detail="仅草稿或已驳回申请可提交")
+    workflow = _get_vehicle_recharge_workflow(db, tenant_no)
+    nodes = _workflow_nodes(db, workflow.id, tenant_no)
+    if not nodes:
+        raise HTTPException(status_code=400, detail="充值审批流程未配置节点")
+    first = nodes[0]
+    now = datetime.now()
+    row.wf_id = workflow.id
+    row.current_node = first.id
+    row.approval_round = (row.approval_round or 0) + 1
+    row.status = "in_approval"
+    row.update_by = user.id
+    row.update_time = now
+    db.commit()
+    db.refresh(row)
+    return apply_to_read(row, db, include_detail=True)
+
+
 def delete_applies(
     db: Session, tenant_no: str, user: DbUserContext, apply_ids: list[str]
 ) -> None:
     for apply_id in apply_ids:
         row = _get_apply_row(db, tenant_no, apply_id, user)
-        if row.status != "pending":
-            raise HTTPException(status_code=400, detail="仅待发放申请可删除")
+        if row.status not in _EDITABLE_STATUSES:
+            raise HTTPException(status_code=400, detail="仅草稿或已驳回申请可删除")
         if _has_active_record(db, tenant_no, apply_id):
             raise HTTPException(status_code=400, detail="已关联发放记录的申请不可删除")
         row.del_flag = 1
@@ -667,7 +801,20 @@ def update_record(
             .first()
         )
         if old_apply:
-            old_apply.status = "pending"
+            if old_apply.wf_id:
+                nodes = _workflow_nodes(db, old_apply.wf_id, tenant_no)
+                disburse = next(
+                    (
+                        n
+                        for n in nodes
+                        if (n.action_type or "approve") == "disburse"
+                    ),
+                    nodes[-1] if nodes else None,
+                )
+                old_apply.status = "in_approval"
+                old_apply.current_node = disburse.id if disburse else None
+            else:
+                old_apply.status = "pending"
             old_apply.update_by = user.id
             old_apply.update_time = now
 
@@ -716,7 +863,20 @@ def delete_records(
                 .first()
             )
             if apply and apply.status == "disbursed":
-                apply.status = "pending"
+                if apply.wf_id:
+                    nodes = _workflow_nodes(db, apply.wf_id, tenant_no)
+                    disburse = next(
+                        (
+                            n
+                            for n in nodes
+                            if (n.action_type or "approve") == "disburse"
+                        ),
+                        nodes[-1] if nodes else None,
+                    )
+                    apply.status = "in_approval"
+                    apply.current_node = disburse.id if disburse else None
+                else:
+                    apply.status = "pending"
                 apply.update_by = user.id
                 apply.update_time = now
     db.commit()

+ 181 - 0
backend/tests/test_pl_vehicle_recharge_workflow.py

@@ -1,4 +1,19 @@
 """车辆充值审批工作流:状态常量与核心流转单元测试。"""
+from __future__ import annotations
+
+from datetime import date, datetime
+from types import SimpleNamespace
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from app.models.bx_models import BxWorkflowDef, BxWorkflowNode
+from app.models.pl_models import (
+    PlVehicle,
+    PlVehicleRechargeApply,
+    PlVehicleRechargeApproval,
+    PlVehicleRechargeRecord,
+)
 from app.schemas.pl.vehicle_recharge import APPLY_STATUSES, NODE_ACTION_TYPES, RECHARGE_TYPES
 
 
@@ -6,3 +21,169 @@ def test_apply_statuses_include_workflow_states():
     assert {"draft", "in_approval", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
     assert {"approve", "disburse"} <= NODE_ACTION_TYPES
     assert {"etc", "fuel"} <= RECHARGE_TYPES
+
+
+def _make_session():
+    engine = create_engine("sqlite://")
+    for table in (
+        PlVehicle.__table__,
+        PlVehicleRechargeApply.__table__,
+        PlVehicleRechargeApproval.__table__,
+        PlVehicleRechargeRecord.__table__,
+        BxWorkflowDef.__table__,
+        BxWorkflowNode.__table__,
+    ):
+        table.create(engine)
+    return sessionmaker(bind=engine)()
+
+
+def _user(uid: str = "u-applicant", name: str = "申请人") -> SimpleNamespace:
+    return SimpleNamespace(id=uid, name=name, dept_id="d1", role="admin", tenant_no="t1")
+
+
+def _seed_vehicle(db, plate: str = "鄂A00001") -> None:
+    db.add(
+        PlVehicle(
+            id="veh-1",
+            vehicle_type="self",
+            license_plate=plate,
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+
+
+def _seed_workflow(db) -> tuple[str, str, str]:
+    wf_id = "wf-1"
+    node_gm = "node-gm"
+    node_fin = "node-fin"
+    db.add(
+        BxWorkflowDef(
+            id=wf_id,
+            biz_type="vehicle_recharge",
+            wf_name="充值流程",
+            status=1,
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    db.add(
+        BxWorkflowNode(
+            id=node_gm,
+            wf_id=wf_id,
+            node_name="总经理审批",
+            node_order=1,
+            approver_id="u-gm",
+            approver_name="总经理",
+            action_type="approve",
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    db.add(
+        BxWorkflowNode(
+            id=node_fin,
+            wf_id=wf_id,
+            node_name="财务发放",
+            node_order=2,
+            approver_id="u-fin",
+            approver_name="财务",
+            action_type="disburse",
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    return wf_id, node_gm, node_fin
+
+
+def _patch_scope(monkeypatch, *modules) -> None:
+    for mod in modules:
+        monkeypatch.setattr(mod, "apply_pl_scope", lambda query, *_a, **_k: query)
+        monkeypatch.setattr(mod, "assert_pl_row_in_scope", lambda *_a, **_k: None)
+    from app.services import pl_vehicle_recharge_service as svc
+
+    monkeypatch.setattr(svc, "_resolve_user_name", lambda *_a, **_k: "测试用户")
+    monkeypatch.setattr(
+        svc, "_resolve_applicant_id", lambda *_a, **_k: "u-applicant"
+    )
+
+
+def test_submit_sets_in_approval_and_first_node(monkeypatch):
+    from app.services import pl_vehicle_recharge_service as svc
+    from app.schemas.pl.vehicle_recharge import VehicleRechargeApplyCreate
+
+    db = _make_session()
+    _seed_vehicle(db)
+    wf_id, node_gm, _node_fin = _seed_workflow(db)
+    db.commit()
+
+    _patch_scope(monkeypatch, svc)
+
+    user = _user()
+    created = svc.create_apply(
+        db,
+        "t1",
+        user,
+        VehicleRechargeApplyCreate(
+            licensePlate="鄂A00001",
+            rechargeType="etc",
+            applyDate=date(2026, 9, 26),
+        ),
+    )
+    assert created.status == "draft"
+    assert created.approval_round == 0
+
+    result = svc.submit_apply(db, "t1", user, created.id)
+    assert result.status == "in_approval"
+    assert result.approval_round == 1
+    assert result.wf_id == wf_id
+    assert result.current_node == node_gm
+
+
+def test_resubmit_increments_round_keeps_history(monkeypatch):
+    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)
+    apply = PlVehicleRechargeApply(
+        id="apply-1",
+        apply_no="ETC2026092601",
+        license_plate="鄂A00001",
+        recharge_type="etc",
+        apply_date=date(2026, 9, 26),
+        applicant_id="u-applicant",
+        status="rejected",
+        wf_id=wf_id,
+        current_node=None,
+        approval_round=1,
+        tenant_no="t1",
+        create_dept_id="d1",
+    )
+    db.add(apply)
+    db.add(
+        PlVehicleRechargeApproval(
+            id="appr-1",
+            apply_id="apply-1",
+            approval_round=1,
+            node_id=node_gm,
+            node_name="总经理审批",
+            approver_id="u-gm",
+            approver_name="总经理",
+            approval_status="rejected",
+            approval_opinion="驳回",
+            approval_time=datetime(2026, 9, 26, 10, 0, 0),
+            tenant_no="t1",
+            create_dept_id="d1",
+        )
+    )
+    db.commit()
+
+    _patch_scope(monkeypatch, svc)
+
+    result = svc.submit_apply(db, "t1", _user(), "apply-1")
+    assert result.approval_round == 2
+    assert result.status == "in_approval"
+    assert result.current_node == node_gm
+    assert len(result.approvals) == 1
+    assert result.approvals[0].approval_status == "rejected"