feat: add vehicle recharge apply submit and detail approvals

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-26 20:55:43 +08:00
1 parent bdd67d44d9
commit ea8c2c0362
3 files changed
+362 -11

No files matched your search

+10
View File
@@ -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,
@@ -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()
@@ -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"