| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440 |
- """运费结算业务服务"""
- from __future__ import annotations
- import json
- from datetime import date, datetime
- from decimal import Decimal
- from typing import Any, Dict, List, Optional, Tuple
- from fastapi import HTTPException
- from sqlalchemy import func, or_
- from sqlalchemy.orm import Session
- from app.core.utils import generate_uuid
- from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
- from app.models.models import SysUser
- from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
- from app.models.pl_models import (
- PlFreightSettlement,
- PlFreightSettlementApproval,
- PlFreightSettlementTransport,
- PlPlan,
- PlRemittanceVoucher,
- PlTransport,
- )
- from app.schemas.pl.freight import (
- FreightSettlementDisburseBody,
- FreightSettlementSubmitApprovalBody,
- FreightSettlementUpdate,
- )
- _FMT = "%Y-%m-%d %H:%M:%S"
- _DRAFT_SETTLEMENT_STATUSES = ("pending", "rejected")
- TAB_STATUS_MAP = {
- "bills": ("pending", "rejected", "approving", "approved", "confirmed", "cancelled"),
- "approval_pending": ("approving",),
- "approval_done": ("approved", "confirmed"),
- "disburse": ("approved",),
- "settled": ("confirmed",),
- }
- def _resolve_user_display_name(db: Session, user_id: str) -> str:
- uid = (user_id or "").strip()
- if not uid:
- return ""
- u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
- if not u:
- return ""
- return (u.nick_name or u.user_no or "").strip()
- def _occupied_transport_ids_subquery(db: Session, tenant_no: str):
- return (
- db.query(PlFreightSettlementTransport.transport_id)
- .join(
- PlFreightSettlement,
- PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
- )
- .filter(
- PlFreightSettlementTransport.tenant_no == tenant_no,
- PlFreightSettlementTransport.del_flag == 0,
- PlFreightSettlement.tenant_no == tenant_no,
- PlFreightSettlement.del_flag == 0,
- PlFreightSettlement.settlement_status != "cancelled",
- )
- )
- def get_settlement_row(
- db: Session,
- tenant_no: str,
- settlement_id: str,
- *,
- user=None,
- skip_scope: bool = False,
- ) -> PlFreightSettlement:
- row = (
- db.query(PlFreightSettlement)
- .filter(
- PlFreightSettlement.id == settlement_id,
- PlFreightSettlement.tenant_no == tenant_no,
- PlFreightSettlement.del_flag == 0,
- )
- .first()
- )
- if not row:
- raise HTTPException(status_code=404, detail="结算单不存在")
- if user and not skip_scope:
- assert_pl_row_in_scope(db, row, PlFreightSettlement, user, tenant_no)
- return row
- def get_settlement_by_transport_id(
- db: Session,
- *,
- tenant_no: str,
- transport_id: str,
- user=None,
- ) -> Optional[Dict[str, Any]]:
- if not transport_id:
- return None
- row = (
- db.query(PlFreightSettlement)
- .join(
- PlFreightSettlementTransport,
- PlFreightSettlementTransport.settlement_id == PlFreightSettlement.id,
- )
- .filter(
- PlFreightSettlementTransport.transport_id == transport_id,
- PlFreightSettlementTransport.tenant_no == tenant_no,
- PlFreightSettlementTransport.del_flag == 0,
- PlFreightSettlement.tenant_no == tenant_no,
- PlFreightSettlement.del_flag == 0,
- )
- .order_by(PlFreightSettlement.create_time.desc())
- .first()
- )
- if not row:
- return None
- if user:
- assert_pl_row_in_scope(db, row, PlFreightSettlement, user, tenant_no)
- return {
- "id": row.id,
- "settlementNo": row.settlement_no,
- "invoiceAmount": row.invoice_amount,
- "invoiceNo": row.invoice_no,
- "invoiceDate": row.invoice_date,
- "invoiceAttachmentUrls": row.invoice_attachment_urls,
- "settlementStatus": row.settlement_status,
- }
- def unsettled_plans_q_filter(q: str):
- """简易搜索:计划编号 / 客户(供应商) / 车牌 / 承运商名称。"""
- return or_(
- PlPlan.plan_no.contains(q),
- PlPlan.customer_name.contains(q),
- PlPlan.supplier_name.contains(q),
- PlPlan.license_plate.contains(q),
- PlTransport.license_plate.contains(q),
- PlTransport.carrier_name.contains(q),
- )
- def list_unsettled_plans(
- db: Session,
- *,
- tenant_no: str,
- user=None,
- q: Optional[str] = None,
- carrier_id: Optional[str] = None,
- plan_type: Optional[str] = None,
- license_plate: Optional[str] = None,
- ids: Optional[List[str]] = None,
- page: int = 1,
- page_size: int = 20,
- max_page_size: Optional[int] = None,
- ) -> Tuple[int, List[Dict[str, Any]]]:
- occupied = _occupied_transport_ids_subquery(db, tenant_no).subquery()
- query = (
- db.query(PlPlan, PlTransport)
- .join(PlTransport, PlTransport.id == PlPlan.transport_id)
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.archive_status == "archived",
- PlPlan.has_freight == 1,
- PlPlan.follow_status != "cancelled",
- PlPlan.transport_id.isnot(None),
- PlTransport.del_flag == 0,
- PlTransport.freight_amount > 0,
- ~PlPlan.transport_id.in_(occupied),
- )
- )
- if q:
- query = query.filter(unsettled_plans_q_filter(q))
- if carrier_id:
- query = query.filter(PlTransport.carrier_id == carrier_id)
- if plan_type:
- query = query.filter(PlPlan.plan_type == plan_type)
- if license_plate:
- query = query.filter(PlPlan.license_plate.contains(license_plate))
- if ids:
- query = query.filter(PlPlan.id.in_(ids))
- if user:
- query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
- total = query.count()
- page = max(1, page)
- cap = int(max_page_size) if max_page_size else 500
- page_size = min(max(1, page_size), cap)
- rows = (
- query.order_by(PlPlan.update_time.desc())
- .offset((page - 1) * page_size)
- .limit(page_size)
- .all()
- )
- items: List[Dict[str, Any]] = []
- for plan, transport in rows:
- items.append(
- {
- "id": plan.id,
- "planNo": plan.plan_no,
- "planDate": plan.plan_date,
- "planType": plan.plan_type,
- "customerName": plan.customer_name,
- "supplierName": plan.supplier_name,
- "licensePlate": plan.license_plate or transport.license_plate,
- "transportCompany": transport.carrier_name,
- "freightAmount": plan.freight_amount or transport.freight_amount,
- "freightRemark": plan.freight_remark,
- "archiveTime": plan.update_time,
- "transportId": transport.id,
- "transportNo": transport.transport_no,
- "driverName": transport.driver_name,
- "transportMedium": transport.transport_medium,
- "startPoint": transport.start_point,
- "endPoint": transport.end_point,
- }
- )
- return total, items
- def _resolve_transports_for_create(
- db: Session, tenant_no: str, *, plan_ids: Optional[List[str]], transport_ids: Optional[List[str]]
- ) -> List[PlTransport]:
- ids: List[str] = []
- if plan_ids:
- plans = (
- db.query(PlPlan)
- .filter(
- PlPlan.id.in_(plan_ids),
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- )
- .all()
- )
- if len(plans) != len(set(plan_ids)):
- raise HTTPException(status_code=400, detail="存在无效的计划")
- for p in plans:
- if p.archive_status != "archived":
- raise HTTPException(status_code=400, detail=f"计划 {p.plan_no} 未归档,不能结算")
- if int(p.has_freight or 0) != 1:
- raise HTTPException(status_code=400, detail=f"计划 {p.plan_no} 无运输费用")
- if not p.transport_id:
- raise HTTPException(status_code=400, detail=f"计划 {p.plan_no} 未安排运输")
- ids.append(p.transport_id)
- if transport_ids:
- ids.extend(transport_ids)
- ids = list(dict.fromkeys(ids))
- if not ids:
- raise HTTPException(status_code=400, detail="请选择有效的运输单")
- occupied = {r[0] for r in _occupied_transport_ids_subquery(db, tenant_no).all()}
- conflict = [tid for tid in ids if tid in occupied]
- if conflict:
- raise HTTPException(status_code=400, detail="所选运输单已纳入其他结算单")
- transports = (
- db.query(PlTransport)
- .filter(
- PlTransport.id.in_(ids),
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .all()
- )
- if len(transports) != len(ids):
- raise HTTPException(status_code=400, detail="请选择有效的运输单")
- return transports
- def create_settlement(
- db: Session,
- *,
- tenant_no: str,
- plan_ids: Optional[List[str]],
- transport_ids: Optional[List[str]],
- user_id: str,
- dept_id: str,
- settlement_no: str,
- ) -> PlFreightSettlement:
- transports = _resolve_transports_for_create(
- db, tenant_no, plan_ids=plan_ids, transport_ids=transport_ids
- )
- total_freight = sum(Decimal(str(t.freight_amount or 0)) for t in transports)
- settlement = PlFreightSettlement(
- id=generate_uuid(),
- tenant_no=tenant_no,
- settlement_no=settlement_no,
- total_freight=total_freight,
- settlement_status="pending",
- create_by=user_id,
- create_dept_id=dept_id or "",
- )
- db.add(settlement)
- for t in transports:
- db.add(
- PlFreightSettlementTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- settlement_id=settlement.id,
- transport_id=t.id,
- freight_amount=t.freight_amount,
- create_by=user_id,
- create_dept_id=dept_id or "",
- )
- )
- db.commit()
- db.refresh(settlement)
- return settlement
- def _recalc_settlement_total_freight_from_links(
- db: Session,
- settlement_id: str,
- *,
- user_id: str,
- now: Optional[datetime] = None,
- ) -> None:
- # SessionLocal 关闭了 autoflush,须先 flush 再聚合查询,否则 SUM 仍读到旧明细金额
- db.flush()
- total = (
- db.query(func.coalesce(func.sum(PlFreightSettlementTransport.freight_amount), 0))
- .filter(
- PlFreightSettlementTransport.settlement_id == settlement_id,
- PlFreightSettlementTransport.del_flag == 0,
- )
- .scalar()
- )
- settlement = (
- db.query(PlFreightSettlement)
- .filter(
- PlFreightSettlement.id == settlement_id,
- PlFreightSettlement.del_flag == 0,
- )
- .first()
- )
- if not settlement:
- return
- ts = now or datetime.now()
- settlement.total_freight = Decimal(str(total or 0))
- settlement.update_by = user_id
- settlement.update_time = ts
- def sync_draft_settlement_freight_for_transport(
- db: Session,
- *,
- tenant_no: str,
- transport_id: str,
- freight_amount: Decimal,
- user_id: str,
- ) -> None:
- """运输单运费变更后,同步关联草稿结算单的明细快照与合计。"""
- tid = (transport_id or "").strip()
- if not tid:
- return
- links = (
- db.query(PlFreightSettlementTransport)
- .join(
- PlFreightSettlement,
- PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
- )
- .filter(
- PlFreightSettlementTransport.transport_id == tid,
- PlFreightSettlementTransport.tenant_no == tenant_no,
- PlFreightSettlementTransport.del_flag == 0,
- PlFreightSettlement.tenant_no == tenant_no,
- PlFreightSettlement.del_flag == 0,
- PlFreightSettlement.settlement_status.in_(_DRAFT_SETTLEMENT_STATUSES),
- )
- .all()
- )
- if not links:
- return
- now = datetime.now()
- amount = Decimal(str(freight_amount or 0))
- settlement_ids = set()
- for link in links:
- link.freight_amount = amount
- link.update_by = user_id
- link.update_time = now
- settlement_ids.add(link.settlement_id)
- for settlement_id in settlement_ids:
- _recalc_settlement_total_freight_from_links(
- db, settlement_id, user_id=user_id, now=now
- )
- def refresh_draft_settlement_freight(
- db: Session,
- settlement: PlFreightSettlement,
- *,
- user_id: str = "",
- ) -> bool:
- """草稿/已驳回结算单按当前运输单运费刷新明细快照与合计。"""
- if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
- return False
- links = (
- db.query(PlFreightSettlementTransport)
- .filter(
- PlFreightSettlementTransport.settlement_id == settlement.id,
- PlFreightSettlementTransport.del_flag == 0,
- )
- .all()
- )
- if not links:
- return False
- transport_ids = [link.transport_id for link in links]
- transports = {
- t.id: t
- for t in db.query(PlTransport)
- .filter(PlTransport.id.in_(transport_ids), PlTransport.del_flag == 0)
- .all()
- }
- old_total = Decimal(str(settlement.total_freight or 0))
- now = datetime.now()
- changed = False
- for link in links:
- transport = transports.get(link.transport_id)
- if not transport:
- continue
- new_amount = Decimal(str(transport.freight_amount or 0))
- if Decimal(str(link.freight_amount or 0)) != new_amount:
- link.freight_amount = new_amount
- link.update_time = now
- if user_id:
- link.update_by = user_id
- changed = True
- _recalc_settlement_total_freight_from_links(
- db, settlement.id, user_id=user_id, now=now
- )
- new_total = Decimal(str(settlement.total_freight or 0))
- return changed or new_total != old_total
- def delete_draft_settlement(
- db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
- ) -> None:
- settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
- if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
- raise HTTPException(status_code=400, detail="仅草稿或已驳回结算单可删除")
- if user:
- assert_row_deletable_by_creator(settlement, user_info_from_context(user))
- elif user_id:
- assert_row_deletable_by_creator(settlement, {"user_id": user_id})
- now = datetime.now()
- settlement.del_flag = 1
- settlement.update_by = user_id
- settlement.update_time = now
- links = (
- db.query(PlFreightSettlementTransport)
- .filter(
- PlFreightSettlementTransport.settlement_id == settlement_id,
- PlFreightSettlementTransport.del_flag == 0,
- )
- .all()
- )
- for link in links:
- link.del_flag = 1
- link.update_time = now
- db.commit()
- def cancel_draft_settlement(
- db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
- ) -> PlFreightSettlement:
- settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
- if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
- raise HTTPException(status_code=400, detail="仅草稿或已驳回结算单可作废")
- if user:
- assert_row_deletable_by_creator(settlement, user_info_from_context(user))
- elif user_id:
- assert_row_deletable_by_creator(settlement, {"user_id": user_id})
- now = datetime.now()
- settlement.settlement_status = "cancelled"
- settlement.update_by = user_id
- settlement.update_time = now
- links = (
- db.query(PlFreightSettlementTransport)
- .filter(
- PlFreightSettlementTransport.settlement_id == settlement_id,
- PlFreightSettlementTransport.del_flag == 0,
- )
- .all()
- )
- for link in links:
- link.del_flag = 1
- link.update_time = now
- db.commit()
- db.refresh(settlement)
- return settlement
- def _get_transport_ids(db: Session, settlement_id: str) -> List[str]:
- return [
- r.transport_id
- for r in db.query(PlFreightSettlementTransport)
- .filter(
- PlFreightSettlementTransport.settlement_id == settlement_id,
- PlFreightSettlementTransport.del_flag == 0,
- )
- .all()
- ]
- def _get_plans_for_transports(db: Session, tenant_no: str, transport_ids: List[str]) -> List[PlPlan]:
- if not transport_ids:
- return []
- return (
- db.query(PlPlan)
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.transport_id.in_(transport_ids),
- )
- .all()
- )
- def _approval_to_dict(row: PlFreightSettlementApproval) -> Dict[str, Any]:
- return {
- "id": row.id,
- "step": row.step,
- "stepName": row.step_name,
- "approverId": row.approver_id,
- "approverName": row.approver_name,
- "approvalType": row.approval_type,
- "approvalResult": row.approval_result,
- "approvalOpinion": row.approval_opinion,
- "approvalTime": row.approval_time,
- "isCurrent": row.is_current,
- }
- def create_settlement_approval_records(
- db: Session,
- *,
- tenant_no: str,
- settlement_id: str,
- approval_type: str,
- approver_ids: List[str],
- user_id: str,
- dept_id: str,
- ) -> None:
- approval_type = (approval_type or "sequential").strip() or "sequential"
- cleaned = [aid.strip() for aid in approver_ids if (aid or "").strip()]
- if not cleaned:
- raise HTTPException(status_code=400, detail="请选择审批人")
- if approval_type in ("parallel", "or"):
- step = 1
- for idx, aid in enumerate(cleaned):
- nick = _resolve_user_display_name(db, aid) or f"用户{aid}"
- db.add(
- PlFreightSettlementApproval(
- id=generate_uuid(),
- settlement_id=settlement_id,
- step=step,
- step_name="会签审批" if approval_type == "parallel" else "或签审批",
- approver_id=aid,
- approver_name=nick,
- approval_type=approval_type,
- approval_result="pending",
- is_current=1,
- tenant_no=tenant_no,
- create_by=user_id,
- create_dept_id=dept_id or "",
- )
- )
- else:
- step = 1
- for idx, aid in enumerate(cleaned):
- nick = _resolve_user_display_name(db, aid) or f"用户{aid}"
- db.add(
- PlFreightSettlementApproval(
- id=generate_uuid(),
- settlement_id=settlement_id,
- step=step,
- step_name=f"第{step}步审批",
- approver_id=aid,
- approver_name=nick,
- approval_type="sequential",
- approval_result="pending",
- is_current=1 if idx == 0 else 0,
- tenant_no=tenant_no,
- create_by=user_id,
- create_dept_id=dept_id or "",
- )
- )
- step += 1
- db.flush()
- def _clear_settlement_approval_records(db: Session, settlement_id: str) -> None:
- """软删除结算单历史审批记录(重新提交审批前清理)"""
- now = datetime.now()
- rows = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .all()
- )
- for row in rows:
- row.del_flag = 1
- row.update_time = now
- if rows:
- db.flush()
- def _sync_settlement_status_if_approval_complete(
- db: Session, settlement: PlFreightSettlement
- ) -> bool:
- """审批记录已全部完成但结算单仍停留在 approving 时自动纠正(兼容历史脏数据)"""
- if settlement.settlement_status != "approving":
- return False
- if not _settlement_all_approved(db, settlement.id):
- return False
- settlement.settlement_status = "approved"
- settlement.update_time = datetime.now()
- return True
- def _sync_settlement_status_if_disbursed(
- db: Session, settlement: PlFreightSettlement
- ) -> bool:
- """已有汇款凭证但结算单仍停留在 approved 时自动纠正(兼容历史脏数据)"""
- if settlement.settlement_status != "approved":
- return False
- voucher = (
- db.query(PlRemittanceVoucher)
- .filter(
- PlRemittanceVoucher.settlement_id == settlement.id,
- PlRemittanceVoucher.del_flag == 0,
- )
- .first()
- )
- if not voucher:
- return False
- settlement.settlement_status = "confirmed"
- settlement.update_time = datetime.now()
- return True
- def _sync_disbursed_settlements(db: Session, tenant_no: str) -> None:
- """批量纠正已有汇款凭证但仍为 approved 的结算单"""
- rows = (
- db.query(PlFreightSettlement)
- .filter(
- PlFreightSettlement.tenant_no == tenant_no,
- PlFreightSettlement.del_flag == 0,
- PlFreightSettlement.settlement_status == "approved",
- db.query(PlRemittanceVoucher.id)
- .filter(
- PlRemittanceVoucher.settlement_id == PlFreightSettlement.id,
- PlRemittanceVoucher.del_flag == 0,
- )
- .exists(),
- )
- .all()
- )
- if not rows:
- return
- now = datetime.now()
- for row in rows:
- row.settlement_status = "confirmed"
- row.update_time = now
- db.commit()
- def can_user_approve_settlement(db: Session, settlement_id: str, user_id: str) -> bool:
- if not settlement_id or not user_id:
- return False
- approvals = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .order_by(PlFreightSettlementApproval.step.asc())
- .all()
- )
- if not approvals:
- return False
- user_approval = next((a for a in approvals if a.approver_id == user_id), None)
- if not user_approval or user_approval.approval_result != "pending" or user_approval.is_current != 1:
- return False
- approval_type = user_approval.approval_type or "sequential"
- if approval_type == "sequential":
- for approval in approvals:
- if approval.step == user_approval.step:
- break
- if approval.approval_result != "approved":
- return False
- return True
- def _settlement_all_approved(db: Session, settlement_id: str) -> bool:
- pending = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.approval_result == "pending",
- PlFreightSettlementApproval.del_flag == 0,
- )
- .count()
- )
- return pending == 0
- def _advance_after_approval(
- db: Session, settlement: PlFreightSettlement, current: PlFreightSettlementApproval, result: str
- ) -> None:
- now = datetime.now()
- if result == "rejected":
- settlement.settlement_status = "rejected"
- settlement.update_time = now
- others = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement.id,
- PlFreightSettlementApproval.id != current.id,
- PlFreightSettlementApproval.approval_result == "pending",
- PlFreightSettlementApproval.del_flag == 0,
- )
- .all()
- )
- for row in others:
- row.approval_result = "skipped"
- row.is_current = 0
- row.update_time = now
- return
- approval_type = current.approval_type or "sequential"
- step = current.step
- if approval_type == "or":
- others = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement.id,
- PlFreightSettlementApproval.step == step,
- PlFreightSettlementApproval.id != current.id,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .all()
- )
- for row in others:
- if row.approval_result == "pending":
- row.approval_result = "skipped"
- row.is_current = 0
- row.update_time = now
- elif approval_type == "parallel":
- step_pending = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement.id,
- PlFreightSettlementApproval.step == step,
- PlFreightSettlementApproval.approval_result == "pending",
- PlFreightSettlementApproval.del_flag == 0,
- )
- .count()
- )
- if step_pending > 0:
- return
- else:
- nxt = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement.id,
- PlFreightSettlementApproval.approval_result == "pending",
- PlFreightSettlementApproval.step > step,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .order_by(PlFreightSettlementApproval.step.asc())
- .first()
- )
- if nxt:
- nxt.is_current = 1
- nxt.update_time = now
- return
- if _settlement_all_approved(db, settlement.id):
- settlement.settlement_status = "approved"
- settlement.update_time = now
- def submit_settlement_approval(
- db: Session,
- *,
- tenant_no: str,
- settlement_id: str,
- body: FreightSettlementSubmitApprovalBody,
- user_id: str,
- dept_id: str,
- user=None,
- ) -> PlFreightSettlement:
- settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
- if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
- raise HTTPException(status_code=400, detail="仅草稿或已驳回状态可提交审批")
- settlement.settlement_status = "approving"
- if body.remark:
- settlement.remark = body.remark
- settlement.update_by = user_id
- db.flush()
- _clear_settlement_approval_records(db, settlement_id)
- create_settlement_approval_records(
- db,
- tenant_no=tenant_no,
- settlement_id=settlement_id,
- approval_type=body.approval_type,
- approver_ids=body.approver_ids,
- user_id=user_id,
- dept_id=dept_id,
- )
- db.commit()
- db.refresh(settlement)
- return settlement
- def process_settlement_approval(
- db: Session,
- *,
- tenant_no: str,
- approval_id: str,
- action: str,
- comment: Optional[str],
- user_id: str,
- ) -> PlFreightSettlement:
- approval = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.id == approval_id,
- PlFreightSettlementApproval.tenant_no == tenant_no,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .first()
- )
- if not approval:
- raise HTTPException(status_code=404, detail="审批记录不存在")
- settlement = get_settlement_row(db, tenant_no, approval.settlement_id, skip_scope=True)
- if settlement.settlement_status != "approving":
- raise HTTPException(status_code=400, detail="结算单不在审批中")
- if approval.approver_id != user_id:
- raise HTTPException(status_code=403, detail="只有审批人才能审批")
- if not can_user_approve_settlement(db, settlement.id, user_id):
- raise HTTPException(status_code=400, detail="当前不可审批")
- result = "approved" if action == "approve" else "rejected"
- now = datetime.now()
- approval.approval_result = result
- approval.approval_opinion = comment or ""
- approval.approval_time = now
- approval.is_current = 0
- approval.update_by = user_id
- approval.update_time = now
- # SessionLocal 关闭了 autoflush,须先 flush 再查询 pending 计数,否则当前审批仍会被算作 pending
- db.flush()
- _advance_after_approval(db, settlement, approval, result)
- settlement.update_by = user_id
- db.commit()
- db.refresh(settlement)
- return settlement
- def withdraw_settlement_approval(
- db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
- ) -> PlFreightSettlement:
- settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
- if settlement.settlement_status != "approving":
- raise HTTPException(status_code=400, detail="仅审批中可撤回")
- if settlement.create_by != user_id:
- raise HTTPException(status_code=403, detail="仅提交人可撤回")
- now = datetime.now()
- rows = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .all()
- )
- for row in rows:
- row.del_flag = 1
- row.update_time = now
- settlement.settlement_status = "pending"
- settlement.update_by = user_id
- settlement.update_time = now
- db.commit()
- db.refresh(settlement)
- return settlement
- def disburse_settlement(
- db: Session,
- *,
- tenant_no: str,
- settlement_id: str,
- body: FreightSettlementDisburseBody,
- user_id: str,
- dept_id: str,
- user=None,
- ) -> PlFreightSettlement:
- settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
- if settlement.settlement_status != "approved":
- raise HTTPException(status_code=400, detail="仅待发放状态可登记")
- total = Decimal(str(settlement.total_freight or 0))
- amount = Decimal(str(body.remittance_amount))
- if amount != total and not (body.remark or "").strip():
- raise HTTPException(status_code=400, detail="汇款金额与运费合计不一致时备注必填")
- db.add(
- PlRemittanceVoucher(
- id=generate_uuid(),
- tenant_no=tenant_no,
- 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,
- create_dept_id=dept_id or "",
- )
- )
- settlement.settlement_status = "confirmed"
- settlement.update_by = user_id
- db.commit()
- db.refresh(settlement)
- return settlement
- def get_approval_flow(db: Session, settlement_id: str) -> List[Dict[str, Any]]:
- rows = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .order_by(PlFreightSettlementApproval.step.asc(), PlFreightSettlementApproval.create_time.asc())
- .all()
- )
- return [_approval_to_dict(r) for r in rows]
- def get_approval_history(db: Session, settlement_id: str) -> List[Dict[str, Any]]:
- """返回全部已完成的审批动作(含撤回/重提前的历史轮次),按时间正序。"""
- rows = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.approval_result.in_(["approved", "rejected"]),
- )
- .order_by(
- PlFreightSettlementApproval.approval_time.asc(),
- PlFreightSettlementApproval.create_time.asc(),
- )
- .all()
- )
- return [_approval_to_dict(r) for r in rows]
- def _current_approver_name(db: Session, settlement_id: str) -> Optional[str]:
- row = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.approval_result == "pending",
- PlFreightSettlementApproval.is_current == 1,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .order_by(PlFreightSettlementApproval.step.asc())
- .first()
- )
- return row.approver_name if row else None
- def _current_approval_id(db: Session, settlement_id: str, user_id: str) -> Optional[str]:
- if not can_user_approve_settlement(db, settlement_id, user_id):
- return None
- row = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.approver_id == user_id,
- PlFreightSettlementApproval.approval_result == "pending",
- PlFreightSettlementApproval.is_current == 1,
- PlFreightSettlementApproval.del_flag == 0,
- )
- .first()
- )
- return row.id if row else None
- def _last_approval_time(db: Session, settlement_id: str) -> Optional[datetime]:
- row = (
- db.query(PlFreightSettlementApproval)
- .filter(
- PlFreightSettlementApproval.settlement_id == settlement_id,
- PlFreightSettlementApproval.approval_result == "approved",
- PlFreightSettlementApproval.del_flag == 0,
- )
- .order_by(PlFreightSettlementApproval.approval_time.desc())
- .first()
- )
- return row.approval_time if row else None
- def enrich_settlement_list_item(
- db: Session, settlement: PlFreightSettlement, user_id: str
- ) -> Dict[str, Any]:
- transport_ids = _get_transport_ids(db, settlement.id)
- plans = _get_plans_for_transports(db, settlement.tenant_no, transport_ids)
- plates = [p.license_plate for p in plans if p.license_plate]
- if not plates:
- transports = (
- db.query(PlTransport)
- .filter(PlTransport.id.in_(transport_ids), PlTransport.del_flag == 0)
- .all()
- if transport_ids
- else []
- )
- plates = [t.license_plate for t in transports if t.license_plate]
- voucher = (
- db.query(PlRemittanceVoucher)
- .filter(
- PlRemittanceVoucher.settlement_id == settlement.id,
- PlRemittanceVoucher.del_flag == 0,
- )
- .order_by(PlRemittanceVoucher.create_time.desc())
- .first()
- )
- submitter = _resolve_user_display_name(db, settlement.create_by or "")
- item = {
- "id": settlement.id,
- "settlementNo": settlement.settlement_no,
- "totalFreight": settlement.total_freight,
- "invoiceAmount": settlement.invoice_amount,
- "invoiceNo": settlement.invoice_no,
- "invoiceDate": settlement.invoice_date,
- "invoiceAttachmentUrls": settlement.invoice_attachment_urls,
- "settlementStatus": settlement.settlement_status,
- "remark": settlement.remark,
- "tenantNo": settlement.tenant_no,
- "orderNum": settlement.order_num,
- "createBy": settlement.create_by,
- "createDeptId": settlement.create_dept_id,
- "createTime": settlement.create_time,
- "updateBy": settlement.update_by,
- "updateTime": settlement.update_time,
- "delFlag": settlement.del_flag,
- "planCount": len(plans),
- "licensePlateSummary": "、".join(plates[:3]) + ("…" if len(plates) > 3 else ""),
- "currentApproverName": _current_approver_name(db, settlement.id),
- "canApprove": can_user_approve_settlement(db, settlement.id, user_id),
- "canWithdraw": settlement.settlement_status == "approving" and settlement.create_by == user_id,
- "currentApprovalId": _current_approval_id(db, settlement.id, user_id),
- "approvalFinishedTime": _last_approval_time(db, settlement.id),
- "disburseTime": voucher.create_time if voucher else None,
- "disburseAmount": voucher.remittance_amount if voucher else None,
- "submitterName": submitter or None,
- }
- return item
- def list_settlements_enriched(
- db: Session,
- *,
- tenant_no: str,
- user_id: str,
- user=None,
- q: Optional[str] = None,
- carrier_id: Optional[str] = None,
- settlement_status: Optional[str] = None,
- tab: Optional[str] = None,
- ids: Optional[List[str]] = None,
- page: int = 1,
- page_size: int = 20,
- max_page_size: Optional[int] = None,
- ) -> Tuple[int, List[Dict[str, Any]]]:
- _sync_disbursed_settlements(db, tenant_no)
- query = db.query(PlFreightSettlement).filter(
- PlFreightSettlement.tenant_no == tenant_no,
- PlFreightSettlement.del_flag == 0,
- )
- if tab and tab in TAB_STATUS_MAP:
- tab_statuses = TAB_STATUS_MAP[tab]
- if settlement_status and settlement_status in tab_statuses:
- query = query.filter(PlFreightSettlement.settlement_status == settlement_status)
- else:
- query = query.filter(PlFreightSettlement.settlement_status.in_(tab_statuses))
- elif settlement_status:
- query = query.filter(PlFreightSettlement.settlement_status == settlement_status)
- if q:
- query = query.filter(PlFreightSettlement.settlement_no.contains(q))
- if carrier_id:
- carrier_settlement_ids = (
- db.query(PlFreightSettlementTransport.settlement_id)
- .join(PlTransport, PlTransport.id == PlFreightSettlementTransport.transport_id)
- .filter(
- PlFreightSettlementTransport.tenant_no == tenant_no,
- PlFreightSettlementTransport.del_flag == 0,
- PlTransport.del_flag == 0,
- PlTransport.carrier_id == carrier_id,
- )
- .distinct()
- )
- query = query.filter(PlFreightSettlement.id.in_(carrier_settlement_ids))
- if ids:
- query = query.filter(PlFreightSettlement.id.in_(ids))
- if user and tab != "approval_pending":
- query = apply_pl_scope(query, PlFreightSettlement, user, db, tenant_no)
- total = query.count()
- page = max(1, page)
- cap = int(max_page_size) if max_page_size else 500
- page_size = min(max(1, page_size), cap)
- rows = (
- query.order_by(PlFreightSettlement.create_time.desc())
- .offset((page - 1) * page_size)
- .limit(page_size)
- .all()
- )
- status_fixed = False
- freight_fixed = False
- for row in rows:
- if _sync_settlement_status_if_approval_complete(db, row):
- status_fixed = True
- if _sync_settlement_status_if_disbursed(db, row):
- status_fixed = True
- if refresh_draft_settlement_freight(db, row, user_id=user_id):
- freight_fixed = True
- if status_fixed or freight_fixed:
- db.commit()
- for row in rows:
- db.refresh(row)
- if tab == "bills":
- bills_statuses = set(TAB_STATUS_MAP["bills"])
- rows = [r for r in rows if r.settlement_status in bills_statuses]
- return total, [enrich_settlement_list_item(db, r, user_id) for r in rows]
- def get_settlement_detail(
- db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
- ) -> Dict[str, Any]:
- settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
- if _sync_settlement_status_if_approval_complete(db, settlement):
- db.commit()
- db.refresh(settlement)
- if _sync_settlement_status_if_disbursed(db, settlement):
- db.commit()
- db.refresh(settlement)
- if refresh_draft_settlement_freight(db, settlement, user_id=user_id):
- db.commit()
- db.refresh(settlement)
- base = enrich_settlement_list_item(db, settlement, user_id)
- transport_ids = _get_transport_ids(db, settlement_id)
- transports = (
- db.query(PlTransport)
- .filter(PlTransport.id.in_(transport_ids), PlTransport.del_flag == 0)
- .all()
- if transport_ids
- else []
- )
- plans = _get_plans_for_transports(db, tenant_no, transport_ids)
- plan_summaries = [
- {
- "id": p.id,
- "planNo": p.plan_no,
- "planDate": p.plan_date,
- "licensePlate": p.license_plate,
- "freightAmount": p.freight_amount,
- "freightRemark": p.freight_remark,
- }
- for p in plans
- ]
- vouchers = (
- db.query(PlRemittanceVoucher)
- .filter(
- PlRemittanceVoucher.settlement_id == settlement_id,
- PlRemittanceVoucher.del_flag == 0,
- )
- .order_by(PlRemittanceVoucher.create_time.desc())
- .all()
- )
- transport_payload = []
- for t in transports:
- transport_payload.append(
- {
- "id": t.id,
- "transportNo": t.transport_no,
- "licensePlate": t.license_plate,
- "driverName": t.driver_name,
- "carrierName": t.carrier_name,
- "transportMedium": t.transport_medium,
- "startPoint": t.start_point,
- "endPoint": t.end_point,
- "freightAmount": t.freight_amount,
- "transportStatus": t.transport_status,
- }
- )
- voucher_payload = []
- for v in vouchers:
- voucher_payload.append(
- {
- "id": v.id,
- "settlementId": v.settlement_id,
- "remittanceDate": v.remittance_date,
- "remittanceAmount": v.remittance_amount,
- "voucherUrls": v.voucher_urls,
- "remark": v.remark,
- "createBy": v.create_by,
- "createTime": v.create_time,
- }
- )
- base["transports"] = transport_payload
- base["plans"] = plan_summaries
- base["approvalFlow"] = get_approval_flow(db, settlement_id)
- base["approvalHistory"] = get_approval_history(db, settlement_id)
- base["vouchers"] = voucher_payload
- return base
- def _validate_transports_for_settlement_update(
- db: Session,
- *,
- tenant_no: str,
- settlement_id: str,
- transport_ids: List[str],
- ) -> List[PlTransport]:
- cleaned = list(dict.fromkeys([tid.strip() for tid in transport_ids if (tid or "").strip()]))
- if not cleaned:
- raise HTTPException(status_code=400, detail="至少保留一条关联运输")
- transports = (
- db.query(PlTransport)
- .filter(
- PlTransport.id.in_(cleaned),
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .all()
- )
- if len(transports) != len(cleaned):
- raise HTTPException(status_code=400, detail="存在无效的运输单")
- transport_map = {t.id: t for t in transports}
- current_ids = set(_get_transport_ids(db, settlement_id))
- added_ids = [tid for tid in cleaned if tid not in current_ids]
- if added_ids:
- occupied = {r[0] for r in _occupied_transport_ids_subquery(db, tenant_no).all()}
- conflict = [tid for tid in added_ids if tid in occupied]
- if conflict:
- raise HTTPException(status_code=400, detail="所选运输单已纳入其他结算单")
- plans = (
- db.query(PlPlan)
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.transport_id.in_(added_ids),
- )
- .all()
- )
- plan_by_transport = {p.transport_id: p for p in plans if p.transport_id}
- for tid in added_ids:
- plan = plan_by_transport.get(tid)
- if not plan:
- raise HTTPException(status_code=400, detail="存在未关联计划的运输单")
- if plan.archive_status != "archived":
- raise HTTPException(status_code=400, detail=f"计划 {plan.plan_no} 未归档,不能结算")
- if int(plan.has_freight or 0) != 1:
- raise HTTPException(status_code=400, detail=f"计划 {plan.plan_no} 无运输费用")
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail=f"计划 {plan.plan_no} 已作废")
- amount = Decimal(str(transport_map[tid].freight_amount or 0))
- if amount <= 0:
- raise HTTPException(status_code=400, detail=f"运输单 {transport_map[tid].transport_no} 运费无效")
- return [transport_map[tid] for tid in cleaned]
- def _sync_settlement_transport_links(
- db: Session,
- *,
- tenant_no: str,
- settlement_id: str,
- transport_ids: List[str],
- transports: List[PlTransport],
- user_id: str,
- dept_id: str,
- ) -> None:
- cleaned = list(dict.fromkeys([tid.strip() for tid in transport_ids if (tid or "").strip()]))
- transport_map = {t.id: t for t in transports}
- now = datetime.now()
- current_links = (
- db.query(PlFreightSettlementTransport)
- .filter(
- PlFreightSettlementTransport.settlement_id == settlement_id,
- PlFreightSettlementTransport.del_flag == 0,
- )
- .all()
- )
- current_map = {link.transport_id: link for link in current_links}
- new_set = set(cleaned)
- for tid, link in current_map.items():
- if tid not in new_set:
- link.del_flag = 1
- link.update_by = user_id
- link.update_time = now
- for tid in cleaned:
- transport = transport_map[tid]
- amount = transport.freight_amount
- if tid in current_map:
- link = current_map[tid]
- if Decimal(str(link.freight_amount or 0)) != Decimal(str(amount or 0)):
- link.freight_amount = amount
- link.update_by = user_id
- link.update_time = now
- continue
- db.add(
- PlFreightSettlementTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- settlement_id=settlement_id,
- transport_id=tid,
- freight_amount=amount,
- create_by=user_id,
- create_dept_id=dept_id or "",
- )
- )
- _recalc_settlement_total_freight_from_links(db, settlement_id, user_id=user_id, now=now)
- def update_settlement_draft(
- db: Session,
- *,
- tenant_no: str,
- settlement_id: str,
- payload: FreightSettlementUpdate,
- user_id: str,
- user=None,
- ) -> PlFreightSettlement:
- settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
- status = settlement.settlement_status
- if status not in (*_DRAFT_SETTLEMENT_STATUSES, "approved"):
- raise HTTPException(status_code=400, detail="当前状态不可编辑")
- update_data = payload.model_dump(exclude_unset=True, by_alias=False)
- transport_ids = update_data.pop("transport_ids", None)
- if status == "approved":
- if transport_ids is not None:
- raise HTTPException(status_code=400, detail="当前状态不可修改关联运输")
- allowed = {"invoice_amount", "invoice_no", "invoice_date", "invoice_attachment_urls", "remark"}
- update_data = {k: v for k, v in update_data.items() if k in allowed}
- if not update_data:
- raise HTTPException(status_code=400, detail="无有效更新字段")
- elif status in _DRAFT_SETTLEMENT_STATUSES and transport_ids is not None:
- dept_id = getattr(user, "dept_id", "") if user else ""
- transports = _validate_transports_for_settlement_update(
- db,
- tenant_no=tenant_no,
- settlement_id=settlement_id,
- transport_ids=transport_ids,
- )
- _sync_settlement_transport_links(
- db,
- tenant_no=tenant_no,
- settlement_id=settlement_id,
- transport_ids=transport_ids,
- transports=transports,
- user_id=user_id,
- dept_id=dept_id,
- )
- for key, value in update_data.items():
- setattr(settlement, key, value)
- settlement.update_by = user_id
- db.commit()
- db.refresh(settlement)
- return settlement
|