From ea8c2c03622bf90374dd23ba69eb853e7a65e32e Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sat, 26 Sep 2026 20:55:43 +0800 Subject: [PATCH] feat: add vehicle recharge apply submit and detail approvals Co-authored-by: Cursor --- backend/app/api/pl/vehicle_recharge.py | 10 + .../services/pl_vehicle_recharge_service.py | 182 ++++++++++++++++-- .../test_pl_vehicle_recharge_workflow.py | 181 +++++++++++++++++ 3 files changed, 362 insertions(+), 11 deletions(-) diff --git a/backend/app/api/pl/vehicle_recharge.py b/backend/app/api/pl/vehicle_recharge.py index 75af57b6d..6ac7fd84e 100644 --- a/backend/app/api/pl/vehicle_recharge.py +++ b/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, diff --git a/backend/app/services/pl_vehicle_recharge_service.py b/backend/app/services/pl_vehicle_recharge_service.py index 8474532cd..ad8e1e08e 100644 --- a/backend/app/services/pl_vehicle_recharge_service.py +++ b/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() diff --git a/backend/tests/test_pl_vehicle_recharge_workflow.py b/backend/tests/test_pl_vehicle_recharge_workflow.py index 6d9d68362..1bc5ebb65 100644 --- a/backend/tests/test_pl_vehicle_recharge_workflow.py +++ b/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"