pl_freight_settlement_service.py 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645
  1. """运费结算业务服务"""
  2. from __future__ import annotations
  3. import json
  4. from datetime import date, datetime
  5. from decimal import Decimal
  6. from typing import Any, Dict, List, Optional, Tuple
  7. from fastapi import HTTPException
  8. from sqlalchemy import exists, false, func, or_
  9. from sqlalchemy.orm import Session
  10. from app.core.utils import generate_uuid
  11. from app.services.wechat_approval_notices import sync_approval_notifications
  12. from app.services.wechat_completion_notices import sync_completion_notification
  13. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  14. from app.models.models import SysUser
  15. from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
  16. from app.services.pl.freight_party_customers import load_plan_party_customers_map
  17. from app.models.pl_models import (
  18. PlFreightSettlement,
  19. PlFreightSettlementApproval,
  20. PlFreightSettlementTransport,
  21. PlPlan,
  22. PlPlanDelivery,
  23. PlPlanLoad,
  24. PlRemittanceVoucher,
  25. PlTransport,
  26. )
  27. from app.schemas.pl.freight import (
  28. FreightSettlementDisburseBody,
  29. FreightSettlementSubmitApprovalBody,
  30. FreightSettlementUpdate,
  31. )
  32. _FMT = "%Y-%m-%d %H:%M:%S"
  33. _DRAFT_SETTLEMENT_STATUSES = ("pending", "rejected")
  34. TAB_STATUS_MAP = {
  35. "bills": ("pending", "rejected", "approving", "approved", "confirmed", "cancelled"),
  36. "approval_pending": ("approving",),
  37. "approval_done": ("approved", "confirmed"),
  38. "disburse": ("approved",),
  39. "settled": ("confirmed",),
  40. }
  41. def _resolve_user_display_name(db: Session, user_id: str) -> str:
  42. uid = (user_id or "").strip()
  43. if not uid:
  44. return ""
  45. u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
  46. if not u:
  47. return ""
  48. return (u.nick_name or u.user_no or "").strip()
  49. def _occupied_transport_ids_subquery(db: Session, tenant_no: str):
  50. return (
  51. db.query(PlFreightSettlementTransport.transport_id)
  52. .join(
  53. PlFreightSettlement,
  54. PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
  55. )
  56. .filter(
  57. PlFreightSettlementTransport.tenant_no == tenant_no,
  58. PlFreightSettlementTransport.del_flag == 0,
  59. PlFreightSettlement.tenant_no == tenant_no,
  60. PlFreightSettlement.del_flag == 0,
  61. PlFreightSettlement.settlement_status != "cancelled",
  62. )
  63. )
  64. def get_settlement_row(
  65. db: Session,
  66. tenant_no: str,
  67. settlement_id: str,
  68. *,
  69. user=None,
  70. skip_scope: bool = False,
  71. for_update: bool = False,
  72. ) -> PlFreightSettlement:
  73. query = (
  74. db.query(PlFreightSettlement)
  75. .filter(
  76. PlFreightSettlement.id == settlement_id,
  77. PlFreightSettlement.tenant_no == tenant_no,
  78. PlFreightSettlement.del_flag == 0,
  79. )
  80. )
  81. if for_update:
  82. query = query.populate_existing().with_for_update()
  83. row = query.first()
  84. if not row:
  85. raise HTTPException(status_code=404, detail="结算单不存在")
  86. if user and not skip_scope:
  87. assert_pl_row_in_scope(db, row, PlFreightSettlement, user, tenant_no)
  88. return row
  89. def get_settlement_by_transport_id(
  90. db: Session,
  91. *,
  92. tenant_no: str,
  93. transport_id: str,
  94. user=None,
  95. ) -> Optional[Dict[str, Any]]:
  96. if not transport_id:
  97. return None
  98. row = (
  99. db.query(PlFreightSettlement)
  100. .join(
  101. PlFreightSettlementTransport,
  102. PlFreightSettlementTransport.settlement_id == PlFreightSettlement.id,
  103. )
  104. .filter(
  105. PlFreightSettlementTransport.transport_id == transport_id,
  106. PlFreightSettlementTransport.tenant_no == tenant_no,
  107. PlFreightSettlementTransport.del_flag == 0,
  108. PlFreightSettlement.tenant_no == tenant_no,
  109. PlFreightSettlement.del_flag == 0,
  110. )
  111. .order_by(PlFreightSettlement.create_time.desc())
  112. .first()
  113. )
  114. if not row:
  115. return None
  116. if user:
  117. assert_pl_row_in_scope(db, row, PlFreightSettlement, user, tenant_no)
  118. return {
  119. "id": row.id,
  120. "settlementNo": row.settlement_no,
  121. "invoiceAmount": row.invoice_amount,
  122. "invoiceNo": row.invoice_no,
  123. "invoiceDate": row.invoice_date,
  124. "invoiceAttachmentUrls": row.invoice_attachment_urls,
  125. "settlementStatus": row.settlement_status,
  126. }
  127. def unsettled_plans_q_filter(q: str):
  128. """简易搜索:计划编号 / 装货送货客户 / 车牌 / 承运商名称。"""
  129. load_match = exists().where(
  130. PlPlanLoad.plan_id == PlPlan.id,
  131. PlPlanLoad.tenant_no == PlPlan.tenant_no,
  132. PlPlanLoad.del_flag == 0,
  133. PlPlanLoad.customer_name.contains(q),
  134. )
  135. delivery_match = exists().where(
  136. PlPlanDelivery.plan_id == PlPlan.id,
  137. PlPlanDelivery.tenant_no == PlPlan.tenant_no,
  138. PlPlanDelivery.del_flag == 0,
  139. PlPlanDelivery.customer_name.contains(q),
  140. )
  141. return or_(
  142. PlPlan.plan_no.contains(q),
  143. PlPlan.customer_name.contains(q),
  144. PlPlan.supplier_name.contains(q),
  145. load_match,
  146. delivery_match,
  147. PlPlan.license_plate.contains(q),
  148. PlTransport.license_plate.contains(q),
  149. PlTransport.carrier_name.contains(q),
  150. )
  151. def list_unsettled_plans(
  152. db: Session,
  153. *,
  154. tenant_no: str,
  155. user=None,
  156. q: Optional[str] = None,
  157. carrier_id: Optional[str] = None,
  158. plan_no: Optional[str] = None,
  159. plan_type: Optional[str] = None,
  160. plan_type_in: Optional[str] = None,
  161. license_plate: Optional[str] = None,
  162. license_plate_in: Optional[str] = None,
  163. transport_company: Optional[str] = None,
  164. transport_company_in: Optional[str] = None,
  165. freight_amount_op: Optional[str] = None,
  166. freight_amount: Optional[str] = None,
  167. freight_amount_min: Optional[str] = None,
  168. freight_amount_max: Optional[str] = None,
  169. freight_type: Optional[str] = None,
  170. freight_type_in: Optional[str] = None,
  171. transport_qty_op: Optional[str] = None,
  172. transport_qty: Optional[str] = None,
  173. transport_qty_min: Optional[str] = None,
  174. transport_qty_max: Optional[str] = None,
  175. has_invoice: Optional[str] = None,
  176. has_invoice_in: Optional[str] = None,
  177. freight_remark: Optional[str] = None,
  178. freight_remark_in: Optional[str] = None,
  179. plan_no_in: Optional[str] = None,
  180. plan_date_from=None,
  181. plan_date_to=None,
  182. freight_price_op: Optional[str] = None,
  183. freight_price: Optional[str] = None,
  184. freight_price_min: Optional[str] = None,
  185. freight_price_max: Optional[str] = None,
  186. archive_time_from=None,
  187. archive_time_to=None,
  188. load_customers: Optional[str] = None,
  189. delivery_customers: Optional[str] = None,
  190. ids: Optional[List[str]] = None,
  191. page: int = 1,
  192. page_size: int = 20,
  193. max_page_size: Optional[int] = None,
  194. ) -> Tuple[int, List[Dict[str, Any]]]:
  195. occupied = _occupied_transport_ids_subquery(db, tenant_no).subquery()
  196. query = (
  197. db.query(PlPlan, PlTransport)
  198. .join(PlTransport, PlTransport.id == PlPlan.transport_id)
  199. .filter(
  200. PlPlan.tenant_no == tenant_no,
  201. PlPlan.del_flag == 0,
  202. PlPlan.archive_status == "archived",
  203. PlPlan.has_freight == 1,
  204. PlPlan.follow_status != "cancelled",
  205. PlPlan.transport_id.isnot(None),
  206. PlTransport.del_flag == 0,
  207. PlTransport.freight_amount > 0,
  208. ~PlPlan.transport_id.in_(occupied),
  209. )
  210. )
  211. if q:
  212. query = query.filter(unsettled_plans_q_filter(q))
  213. if carrier_id:
  214. query = query.filter(PlTransport.carrier_id == carrier_id)
  215. from app.services.pl.freight_list_filters import build_unsettled_plan_column_filters
  216. column_filters = build_unsettled_plan_column_filters(
  217. plan_no=plan_no,
  218. plan_no_in=plan_no_in,
  219. plan_type=plan_type,
  220. plan_type_in=plan_type_in,
  221. license_plate=license_plate,
  222. license_plate_in=license_plate_in,
  223. transport_company=transport_company,
  224. transport_company_in=transport_company_in,
  225. freight_amount_op=freight_amount_op,
  226. freight_amount=freight_amount,
  227. freight_amount_min=freight_amount_min,
  228. freight_amount_max=freight_amount_max,
  229. freight_type=freight_type,
  230. freight_type_in=freight_type_in,
  231. transport_qty_op=transport_qty_op,
  232. transport_qty=transport_qty,
  233. transport_qty_min=transport_qty_min,
  234. transport_qty_max=transport_qty_max,
  235. has_invoice=has_invoice,
  236. has_invoice_in=has_invoice_in,
  237. freight_remark=freight_remark,
  238. freight_remark_in=freight_remark_in,
  239. plan_date_from=plan_date_from,
  240. plan_date_to=plan_date_to,
  241. freight_price_op=freight_price_op,
  242. freight_price=freight_price,
  243. freight_price_min=freight_price_min,
  244. freight_price_max=freight_price_max,
  245. archive_time_from=archive_time_from,
  246. archive_time_to=archive_time_to,
  247. load_customers=load_customers,
  248. delivery_customers=delivery_customers,
  249. )
  250. if column_filters:
  251. query = query.filter(*column_filters)
  252. if ids:
  253. query = query.filter(PlPlan.id.in_(ids))
  254. if user:
  255. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  256. total = query.count()
  257. page = max(1, page)
  258. cap = int(max_page_size) if max_page_size else 500
  259. page_size = min(max(1, page_size), cap)
  260. rows = (
  261. query.order_by(PlPlan.update_time.desc())
  262. .offset((page - 1) * page_size)
  263. .limit(page_size)
  264. .all()
  265. )
  266. party_map = load_plan_party_customers_map(db, tenant_no, [plan for plan, _ in rows])
  267. items: List[Dict[str, Any]] = []
  268. for plan, transport in rows:
  269. load_customers, delivery_customers = party_map.get(plan.id, ([], []))
  270. items.append(
  271. {
  272. "id": plan.id,
  273. "planNo": plan.plan_no,
  274. "planDate": plan.plan_date,
  275. "planType": plan.plan_type,
  276. "customerName": plan.customer_name,
  277. "supplierName": plan.supplier_name,
  278. "loadCustomers": load_customers,
  279. "deliveryCustomers": delivery_customers,
  280. "licensePlate": plan.license_plate or transport.license_plate,
  281. "transportCompany": transport.carrier_name,
  282. "freightAmount": plan.freight_amount or transport.freight_amount,
  283. "freightType": transport.freight_type,
  284. "transportQty": transport.transport_qty,
  285. "freightPrice": transport.freight_price,
  286. "hasInvoice": int(transport.has_invoice or 0),
  287. "freightRemark": plan.freight_remark,
  288. "archiveTime": plan.update_time,
  289. "transportId": transport.id,
  290. "transportNo": transport.transport_no,
  291. "driverName": transport.driver_name,
  292. "transportMedium": transport.transport_medium,
  293. "startPoint": transport.start_point,
  294. "endPoint": transport.end_point,
  295. }
  296. )
  297. return total, items
  298. def _resolve_transports_for_create(
  299. db: Session, tenant_no: str, *, plan_ids: Optional[List[str]], transport_ids: Optional[List[str]]
  300. ) -> List[PlTransport]:
  301. ids: List[str] = []
  302. if plan_ids:
  303. plans = (
  304. db.query(PlPlan)
  305. .filter(
  306. PlPlan.id.in_(plan_ids),
  307. PlPlan.tenant_no == tenant_no,
  308. PlPlan.del_flag == 0,
  309. )
  310. .all()
  311. )
  312. if len(plans) != len(set(plan_ids)):
  313. raise HTTPException(status_code=400, detail="存在无效的计划")
  314. for p in plans:
  315. if p.archive_status != "archived":
  316. raise HTTPException(status_code=400, detail=f"计划 {p.plan_no} 未归档,不能结算")
  317. if int(p.has_freight or 0) != 1:
  318. raise HTTPException(status_code=400, detail=f"计划 {p.plan_no} 无运输费用")
  319. if not p.transport_id:
  320. raise HTTPException(status_code=400, detail=f"计划 {p.plan_no} 未安排运输")
  321. ids.append(p.transport_id)
  322. if transport_ids:
  323. ids.extend(transport_ids)
  324. ids = list(dict.fromkeys(ids))
  325. if not ids:
  326. raise HTTPException(status_code=400, detail="请选择有效的运输单")
  327. occupied = {r[0] for r in _occupied_transport_ids_subquery(db, tenant_no).all()}
  328. conflict = [tid for tid in ids if tid in occupied]
  329. if conflict:
  330. raise HTTPException(status_code=400, detail="所选运输单已纳入其他结算单")
  331. transports = (
  332. db.query(PlTransport)
  333. .filter(
  334. PlTransport.id.in_(ids),
  335. PlTransport.tenant_no == tenant_no,
  336. PlTransport.del_flag == 0,
  337. )
  338. .all()
  339. )
  340. if len(transports) != len(ids):
  341. raise HTTPException(status_code=400, detail="请选择有效的运输单")
  342. return transports
  343. def create_settlement(
  344. db: Session,
  345. *,
  346. tenant_no: str,
  347. plan_ids: Optional[List[str]],
  348. transport_ids: Optional[List[str]],
  349. user_id: str,
  350. dept_id: str,
  351. settlement_no: str,
  352. ) -> PlFreightSettlement:
  353. transports = _resolve_transports_for_create(
  354. db, tenant_no, plan_ids=plan_ids, transport_ids=transport_ids
  355. )
  356. total_freight = sum(Decimal(str(t.freight_amount or 0)) for t in transports)
  357. settlement = PlFreightSettlement(
  358. id=generate_uuid(),
  359. tenant_no=tenant_no,
  360. settlement_no=settlement_no,
  361. total_freight=total_freight,
  362. settlement_status="pending",
  363. create_by=user_id,
  364. create_dept_id=dept_id or "",
  365. )
  366. db.add(settlement)
  367. for t in transports:
  368. db.add(
  369. PlFreightSettlementTransport(
  370. id=generate_uuid(),
  371. tenant_no=tenant_no,
  372. settlement_id=settlement.id,
  373. transport_id=t.id,
  374. freight_amount=t.freight_amount,
  375. create_by=user_id,
  376. create_dept_id=dept_id or "",
  377. )
  378. )
  379. db.commit()
  380. db.refresh(settlement)
  381. return settlement
  382. def _recalc_settlement_total_freight_from_links(
  383. db: Session,
  384. settlement_id: str,
  385. *,
  386. user_id: str,
  387. now: Optional[datetime] = None,
  388. ) -> None:
  389. # SessionLocal 关闭了 autoflush,须先 flush 再聚合查询,否则 SUM 仍读到旧明细金额
  390. db.flush()
  391. total = (
  392. db.query(func.coalesce(func.sum(PlFreightSettlementTransport.freight_amount), 0))
  393. .filter(
  394. PlFreightSettlementTransport.settlement_id == settlement_id,
  395. PlFreightSettlementTransport.del_flag == 0,
  396. )
  397. .scalar()
  398. )
  399. settlement = (
  400. db.query(PlFreightSettlement)
  401. .filter(
  402. PlFreightSettlement.id == settlement_id,
  403. PlFreightSettlement.del_flag == 0,
  404. )
  405. .first()
  406. )
  407. if not settlement:
  408. return
  409. ts = now or datetime.now()
  410. settlement.total_freight = Decimal(str(total or 0))
  411. settlement.update_by = user_id
  412. settlement.update_time = ts
  413. def sync_draft_settlement_freight_for_transport(
  414. db: Session,
  415. *,
  416. tenant_no: str,
  417. transport_id: str,
  418. freight_amount: Decimal,
  419. user_id: str,
  420. ) -> None:
  421. """运输单运费变更后,同步关联草稿结算单的明细快照与合计。"""
  422. tid = (transport_id or "").strip()
  423. if not tid:
  424. return
  425. links = (
  426. db.query(PlFreightSettlementTransport)
  427. .join(
  428. PlFreightSettlement,
  429. PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
  430. )
  431. .filter(
  432. PlFreightSettlementTransport.transport_id == tid,
  433. PlFreightSettlementTransport.tenant_no == tenant_no,
  434. PlFreightSettlementTransport.del_flag == 0,
  435. PlFreightSettlement.tenant_no == tenant_no,
  436. PlFreightSettlement.del_flag == 0,
  437. PlFreightSettlement.settlement_status.in_(_DRAFT_SETTLEMENT_STATUSES),
  438. )
  439. .all()
  440. )
  441. if not links:
  442. return
  443. now = datetime.now()
  444. amount = Decimal(str(freight_amount or 0))
  445. settlement_ids = set()
  446. for link in links:
  447. link.freight_amount = amount
  448. link.update_by = user_id
  449. link.update_time = now
  450. settlement_ids.add(link.settlement_id)
  451. for settlement_id in settlement_ids:
  452. _recalc_settlement_total_freight_from_links(
  453. db, settlement_id, user_id=user_id, now=now
  454. )
  455. def refresh_draft_settlement_freight(
  456. db: Session,
  457. settlement: PlFreightSettlement,
  458. *,
  459. user_id: str = "",
  460. ) -> bool:
  461. """草稿/已驳回结算单按当前运输单运费刷新明细快照与合计。"""
  462. if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
  463. return False
  464. links = (
  465. db.query(PlFreightSettlementTransport)
  466. .filter(
  467. PlFreightSettlementTransport.settlement_id == settlement.id,
  468. PlFreightSettlementTransport.del_flag == 0,
  469. )
  470. .all()
  471. )
  472. if not links:
  473. return False
  474. transport_ids = [link.transport_id for link in links]
  475. transports = {
  476. t.id: t
  477. for t in db.query(PlTransport)
  478. .filter(PlTransport.id.in_(transport_ids), PlTransport.del_flag == 0)
  479. .all()
  480. }
  481. old_total = Decimal(str(settlement.total_freight or 0))
  482. now = datetime.now()
  483. changed = False
  484. for link in links:
  485. transport = transports.get(link.transport_id)
  486. if not transport:
  487. continue
  488. new_amount = Decimal(str(transport.freight_amount or 0))
  489. if Decimal(str(link.freight_amount or 0)) != new_amount:
  490. link.freight_amount = new_amount
  491. link.update_time = now
  492. if user_id:
  493. link.update_by = user_id
  494. changed = True
  495. _recalc_settlement_total_freight_from_links(
  496. db, settlement.id, user_id=user_id, now=now
  497. )
  498. new_total = Decimal(str(settlement.total_freight or 0))
  499. return changed or new_total != old_total
  500. def delete_draft_settlement(
  501. db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
  502. ) -> None:
  503. settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
  504. if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
  505. raise HTTPException(status_code=400, detail="仅草稿或已驳回结算单可删除")
  506. if user:
  507. assert_row_deletable_by_creator(settlement, user_info_from_context(user))
  508. elif user_id:
  509. assert_row_deletable_by_creator(settlement, {"user_id": user_id})
  510. now = datetime.now()
  511. settlement.del_flag = 1
  512. settlement.update_by = user_id
  513. settlement.update_time = now
  514. links = (
  515. db.query(PlFreightSettlementTransport)
  516. .filter(
  517. PlFreightSettlementTransport.settlement_id == settlement_id,
  518. PlFreightSettlementTransport.del_flag == 0,
  519. )
  520. .all()
  521. )
  522. for link in links:
  523. link.del_flag = 1
  524. link.update_time = now
  525. db.flush()
  526. sync_approval_notifications(
  527. db, "pl_freight", settlement_id, tenant_no,
  528. actor_id=user_id or "",
  529. )
  530. db.commit()
  531. def cancel_draft_settlement(
  532. db: Session,
  533. *,
  534. tenant_no: str,
  535. settlement_id: str,
  536. user_id: str,
  537. user=None,
  538. cancel_reason: Optional[str] = None,
  539. ) -> PlFreightSettlement:
  540. from app.schemas.pl.cancel_reason import normalize_optional_reason
  541. settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
  542. if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
  543. raise HTTPException(status_code=400, detail="仅草稿或已驳回结算单可作废")
  544. if user:
  545. assert_row_deletable_by_creator(settlement, user_info_from_context(user))
  546. elif user_id:
  547. assert_row_deletable_by_creator(settlement, {"user_id": user_id})
  548. now = datetime.now()
  549. settlement.settlement_status = "cancelled"
  550. settlement.cancel_reason = normalize_optional_reason(cancel_reason)
  551. settlement.cancel_time = now
  552. settlement.cancel_by = user_id
  553. settlement.update_by = user_id
  554. settlement.update_time = now
  555. links = (
  556. db.query(PlFreightSettlementTransport)
  557. .filter(
  558. PlFreightSettlementTransport.settlement_id == settlement_id,
  559. PlFreightSettlementTransport.del_flag == 0,
  560. )
  561. .all()
  562. )
  563. for link in links:
  564. link.del_flag = 1
  565. link.update_time = now
  566. db.flush()
  567. sync_approval_notifications(
  568. db, "pl_freight", settlement_id, tenant_no,
  569. actor_id=user_id or "",
  570. )
  571. db.commit()
  572. db.refresh(settlement)
  573. return settlement
  574. def _get_transport_ids(db: Session, settlement_id: str) -> List[str]:
  575. return [
  576. r.transport_id
  577. for r in db.query(PlFreightSettlementTransport)
  578. .filter(
  579. PlFreightSettlementTransport.settlement_id == settlement_id,
  580. PlFreightSettlementTransport.del_flag == 0,
  581. )
  582. .all()
  583. ]
  584. def _get_plans_for_transports(db: Session, tenant_no: str, transport_ids: List[str]) -> List[PlPlan]:
  585. if not transport_ids:
  586. return []
  587. return (
  588. db.query(PlPlan)
  589. .filter(
  590. PlPlan.tenant_no == tenant_no,
  591. PlPlan.del_flag == 0,
  592. PlPlan.transport_id.in_(transport_ids),
  593. )
  594. .all()
  595. )
  596. def _approval_to_dict(row: PlFreightSettlementApproval) -> Dict[str, Any]:
  597. return {
  598. "id": row.id,
  599. "step": row.step,
  600. "stepName": row.step_name,
  601. "approverId": row.approver_id,
  602. "approverName": row.approver_name,
  603. "approvalType": row.approval_type,
  604. "approvalResult": row.approval_result,
  605. "approvalOpinion": row.approval_opinion,
  606. "approvalTime": row.approval_time,
  607. "isCurrent": row.is_current,
  608. }
  609. def create_settlement_approval_records(
  610. db: Session,
  611. *,
  612. tenant_no: str,
  613. settlement_id: str,
  614. approval_type: str,
  615. approver_ids: List[str],
  616. user_id: str,
  617. dept_id: str,
  618. ) -> None:
  619. approval_type = (approval_type or "sequential").strip() or "sequential"
  620. cleaned = [aid.strip() for aid in approver_ids if (aid or "").strip()]
  621. if not cleaned:
  622. raise HTTPException(status_code=400, detail="请选择审批人")
  623. if approval_type in ("parallel", "or"):
  624. step = 1
  625. for idx, aid in enumerate(cleaned):
  626. nick = _resolve_user_display_name(db, aid) or f"用户{aid}"
  627. db.add(
  628. PlFreightSettlementApproval(
  629. id=generate_uuid(),
  630. settlement_id=settlement_id,
  631. step=step,
  632. step_name="会签审批" if approval_type == "parallel" else "或签审批",
  633. approver_id=aid,
  634. approver_name=nick,
  635. approval_type=approval_type,
  636. approval_result="pending",
  637. is_current=1,
  638. tenant_no=tenant_no,
  639. create_by=user_id,
  640. create_dept_id=dept_id or "",
  641. )
  642. )
  643. else:
  644. step = 1
  645. for idx, aid in enumerate(cleaned):
  646. nick = _resolve_user_display_name(db, aid) or f"用户{aid}"
  647. db.add(
  648. PlFreightSettlementApproval(
  649. id=generate_uuid(),
  650. settlement_id=settlement_id,
  651. step=step,
  652. step_name=f"第{step}步审批",
  653. approver_id=aid,
  654. approver_name=nick,
  655. approval_type="sequential",
  656. approval_result="pending",
  657. is_current=1 if idx == 0 else 0,
  658. tenant_no=tenant_no,
  659. create_by=user_id,
  660. create_dept_id=dept_id or "",
  661. )
  662. )
  663. step += 1
  664. db.flush()
  665. def _clear_settlement_approval_records(db: Session, settlement_id: str) -> None:
  666. """软删除结算单历史审批记录(重新提交审批前清理)"""
  667. now = datetime.now()
  668. rows = (
  669. db.query(PlFreightSettlementApproval)
  670. .filter(
  671. PlFreightSettlementApproval.settlement_id == settlement_id,
  672. PlFreightSettlementApproval.del_flag == 0,
  673. )
  674. .all()
  675. )
  676. for row in rows:
  677. row.del_flag = 1
  678. row.update_time = now
  679. if rows:
  680. db.flush()
  681. def _sync_settlement_status_if_approval_complete(
  682. db: Session, settlement: PlFreightSettlement
  683. ) -> bool:
  684. """审批记录已全部完成但结算单仍停留在 approving 时自动纠正(兼容历史脏数据)"""
  685. if settlement.settlement_status != "approving":
  686. return False
  687. if not _settlement_all_approved(db, settlement.id):
  688. return False
  689. settlement.settlement_status = "approved"
  690. settlement.update_time = datetime.now()
  691. return True
  692. def _sync_settlement_status_if_disbursed(
  693. db: Session, settlement: PlFreightSettlement
  694. ) -> bool:
  695. """已有汇款凭证但结算单仍停留在 approved 时自动纠正(兼容历史脏数据)"""
  696. if settlement.settlement_status != "approved":
  697. return False
  698. voucher = (
  699. db.query(PlRemittanceVoucher)
  700. .filter(
  701. PlRemittanceVoucher.settlement_id == settlement.id,
  702. PlRemittanceVoucher.del_flag == 0,
  703. )
  704. .first()
  705. )
  706. if not voucher:
  707. return False
  708. settlement.settlement_status = "confirmed"
  709. settlement.update_time = datetime.now()
  710. return True
  711. def _sync_disbursed_settlements(db: Session, tenant_no: str) -> None:
  712. """批量纠正已有汇款凭证但仍为 approved 的结算单"""
  713. rows = (
  714. db.query(PlFreightSettlement)
  715. .filter(
  716. PlFreightSettlement.tenant_no == tenant_no,
  717. PlFreightSettlement.del_flag == 0,
  718. PlFreightSettlement.settlement_status == "approved",
  719. db.query(PlRemittanceVoucher.id)
  720. .filter(
  721. PlRemittanceVoucher.settlement_id == PlFreightSettlement.id,
  722. PlRemittanceVoucher.del_flag == 0,
  723. )
  724. .exists(),
  725. )
  726. .all()
  727. )
  728. if not rows:
  729. return
  730. now = datetime.now()
  731. for row in rows:
  732. row.settlement_status = "confirmed"
  733. row.update_time = now
  734. db.commit()
  735. def can_user_approve_settlement(db: Session, settlement_id: str, user_id: str) -> bool:
  736. if not settlement_id or not user_id:
  737. return False
  738. approvals = (
  739. db.query(PlFreightSettlementApproval)
  740. .filter(
  741. PlFreightSettlementApproval.settlement_id == settlement_id,
  742. PlFreightSettlementApproval.del_flag == 0,
  743. )
  744. .order_by(PlFreightSettlementApproval.step.asc())
  745. .all()
  746. )
  747. if not approvals:
  748. return False
  749. user_approval = next((a for a in approvals if a.approver_id == user_id), None)
  750. if not user_approval or user_approval.approval_result != "pending" or user_approval.is_current != 1:
  751. return False
  752. approval_type = user_approval.approval_type or "sequential"
  753. if approval_type == "sequential":
  754. for approval in approvals:
  755. if approval.step == user_approval.step:
  756. break
  757. if approval.approval_result != "approved":
  758. return False
  759. return True
  760. def _settlement_all_approved(db: Session, settlement_id: str) -> bool:
  761. pending = (
  762. db.query(PlFreightSettlementApproval)
  763. .filter(
  764. PlFreightSettlementApproval.settlement_id == settlement_id,
  765. PlFreightSettlementApproval.approval_result == "pending",
  766. PlFreightSettlementApproval.del_flag == 0,
  767. )
  768. .count()
  769. )
  770. return pending == 0
  771. def _advance_after_approval(
  772. db: Session, settlement: PlFreightSettlement, current: PlFreightSettlementApproval, result: str
  773. ) -> None:
  774. now = datetime.now()
  775. if result == "rejected":
  776. settlement.settlement_status = "rejected"
  777. settlement.update_time = now
  778. others = (
  779. db.query(PlFreightSettlementApproval)
  780. .filter(
  781. PlFreightSettlementApproval.settlement_id == settlement.id,
  782. PlFreightSettlementApproval.id != current.id,
  783. PlFreightSettlementApproval.approval_result == "pending",
  784. PlFreightSettlementApproval.del_flag == 0,
  785. )
  786. .all()
  787. )
  788. for row in others:
  789. row.approval_result = "skipped"
  790. row.is_current = 0
  791. row.update_time = now
  792. return
  793. approval_type = current.approval_type or "sequential"
  794. step = current.step
  795. if approval_type == "or":
  796. others = (
  797. db.query(PlFreightSettlementApproval)
  798. .filter(
  799. PlFreightSettlementApproval.settlement_id == settlement.id,
  800. PlFreightSettlementApproval.step == step,
  801. PlFreightSettlementApproval.id != current.id,
  802. PlFreightSettlementApproval.del_flag == 0,
  803. )
  804. .all()
  805. )
  806. for row in others:
  807. if row.approval_result == "pending":
  808. row.approval_result = "skipped"
  809. row.is_current = 0
  810. row.update_time = now
  811. elif approval_type == "parallel":
  812. step_pending = (
  813. db.query(PlFreightSettlementApproval)
  814. .filter(
  815. PlFreightSettlementApproval.settlement_id == settlement.id,
  816. PlFreightSettlementApproval.step == step,
  817. PlFreightSettlementApproval.approval_result == "pending",
  818. PlFreightSettlementApproval.del_flag == 0,
  819. )
  820. .count()
  821. )
  822. if step_pending > 0:
  823. return
  824. else:
  825. nxt = (
  826. db.query(PlFreightSettlementApproval)
  827. .filter(
  828. PlFreightSettlementApproval.settlement_id == settlement.id,
  829. PlFreightSettlementApproval.approval_result == "pending",
  830. PlFreightSettlementApproval.step > step,
  831. PlFreightSettlementApproval.del_flag == 0,
  832. )
  833. .order_by(PlFreightSettlementApproval.step.asc())
  834. .first()
  835. )
  836. if nxt:
  837. nxt.is_current = 1
  838. nxt.update_time = now
  839. return
  840. # 或签跳过其他节点后先落库;Session autoflush=False,否则仍会统计到 pending。
  841. db.flush()
  842. if _settlement_all_approved(db, settlement.id):
  843. settlement.settlement_status = "approved"
  844. settlement.update_time = now
  845. def submit_settlement_approval(
  846. db: Session,
  847. *,
  848. tenant_no: str,
  849. settlement_id: str,
  850. body: FreightSettlementSubmitApprovalBody,
  851. user_id: str,
  852. dept_id: str,
  853. user=None,
  854. ) -> PlFreightSettlement:
  855. settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
  856. if settlement.settlement_status not in _DRAFT_SETTLEMENT_STATUSES:
  857. raise HTTPException(status_code=400, detail="仅草稿或已驳回状态可提交审批")
  858. settlement.settlement_status = "approving"
  859. if body.remark:
  860. settlement.remark = body.remark
  861. settlement.update_by = user_id
  862. db.flush()
  863. _clear_settlement_approval_records(db, settlement_id)
  864. create_settlement_approval_records(
  865. db,
  866. tenant_no=tenant_no,
  867. settlement_id=settlement_id,
  868. approval_type=body.approval_type,
  869. approver_ids=body.approver_ids,
  870. user_id=user_id,
  871. dept_id=dept_id,
  872. )
  873. db.flush()
  874. sync_approval_notifications(
  875. db, "pl_freight", settlement_id, tenant_no,
  876. new_cycle=True, actor_id=user_id or "",
  877. )
  878. db.commit()
  879. db.refresh(settlement)
  880. return settlement
  881. def process_settlement_approval(
  882. db: Session,
  883. *,
  884. tenant_no: str,
  885. approval_id: str,
  886. action: str,
  887. comment: Optional[str],
  888. user_id: str,
  889. ) -> PlFreightSettlement:
  890. db.query(PlFreightSettlement).join(
  891. PlFreightSettlementApproval,
  892. PlFreightSettlementApproval.settlement_id == PlFreightSettlement.id,
  893. ).filter(
  894. PlFreightSettlementApproval.id == approval_id,
  895. PlFreightSettlementApproval.tenant_no == tenant_no,
  896. PlFreightSettlement.tenant_no == tenant_no,
  897. PlFreightSettlement.del_flag == 0,
  898. ).populate_existing().with_for_update().first()
  899. approval = (
  900. db.query(PlFreightSettlementApproval)
  901. .filter(
  902. PlFreightSettlementApproval.id == approval_id,
  903. PlFreightSettlementApproval.tenant_no == tenant_no,
  904. PlFreightSettlementApproval.del_flag == 0,
  905. )
  906. .first()
  907. )
  908. if not approval:
  909. raise HTTPException(status_code=404, detail="审批记录不存在")
  910. db.refresh(approval, with_for_update=True)
  911. settlement = get_settlement_row(db, tenant_no, approval.settlement_id, skip_scope=True)
  912. if settlement.settlement_status != "approving":
  913. raise HTTPException(status_code=400, detail="结算单不在审批中")
  914. if approval.approver_id != user_id:
  915. raise HTTPException(status_code=403, detail="只有审批人才能审批")
  916. if not can_user_approve_settlement(db, settlement.id, user_id):
  917. raise HTTPException(status_code=400, detail="当前不可审批")
  918. result = "approved" if action == "approve" else "rejected"
  919. now = datetime.now()
  920. approval.approval_result = result
  921. approval.approval_opinion = comment or ""
  922. approval.approval_time = now
  923. approval.is_current = 0
  924. approval.update_by = user_id
  925. approval.update_time = now
  926. # SessionLocal 关闭了 autoflush,须先 flush 再查询 pending 计数,否则当前审批仍会被算作 pending
  927. db.flush()
  928. _advance_after_approval(db, settlement, approval, result)
  929. settlement.update_by = user_id
  930. db.flush()
  931. sync_approval_notifications(
  932. db, "pl_freight", settlement.id, tenant_no,
  933. actor_id=user_id or "",
  934. )
  935. db.commit()
  936. db.refresh(settlement)
  937. return settlement
  938. def withdraw_settlement_approval(
  939. db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
  940. ) -> PlFreightSettlement:
  941. settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
  942. if settlement.settlement_status != "approving":
  943. raise HTTPException(status_code=400, detail="仅审批中可撤回")
  944. if settlement.create_by != user_id:
  945. raise HTTPException(status_code=403, detail="仅提交人可撤回")
  946. now = datetime.now()
  947. rows = (
  948. db.query(PlFreightSettlementApproval)
  949. .filter(
  950. PlFreightSettlementApproval.settlement_id == settlement_id,
  951. PlFreightSettlementApproval.del_flag == 0,
  952. )
  953. .all()
  954. )
  955. for row in rows:
  956. row.del_flag = 1
  957. row.update_time = now
  958. settlement.settlement_status = "pending"
  959. settlement.update_by = user_id
  960. settlement.update_time = now
  961. db.flush()
  962. sync_approval_notifications(
  963. db, "pl_freight", settlement_id, tenant_no,
  964. actor_id=user_id or "",
  965. )
  966. db.commit()
  967. db.refresh(settlement)
  968. return settlement
  969. def disburse_settlement(
  970. db: Session,
  971. *,
  972. tenant_no: str,
  973. settlement_id: str,
  974. body: FreightSettlementDisburseBody,
  975. user_id: str,
  976. dept_id: str,
  977. user=None,
  978. ) -> PlFreightSettlement:
  979. # 锁住结算单,避免并发发放创建两份凭证和两条完成通知。
  980. settlement = get_settlement_row(
  981. db, tenant_no, settlement_id, user=user, for_update=True
  982. )
  983. if settlement.settlement_status != "approved":
  984. raise HTTPException(status_code=400, detail="仅待发放状态可登记")
  985. total = Decimal(str(settlement.total_freight or 0))
  986. amount = Decimal(str(body.remittance_amount))
  987. if amount != total and not (body.remark or "").strip():
  988. raise HTTPException(status_code=400, detail="汇款金额与运费合计不一致时备注必填")
  989. voucher = PlRemittanceVoucher(
  990. id=generate_uuid(),
  991. tenant_no=tenant_no,
  992. settlement_id=settlement.id,
  993. remittance_date=body.remittance_date,
  994. remittance_amount=body.remittance_amount,
  995. voucher_urls=json.dumps(body.voucher_urls),
  996. remark=body.remark,
  997. create_by=user_id,
  998. create_dept_id=dept_id or "",
  999. )
  1000. db.add(voucher)
  1001. settlement.settlement_status = "confirmed"
  1002. settlement.update_by = user_id
  1003. db.flush()
  1004. sync_completion_notification(
  1005. db, "pl_freight", settlement_id, tenant_no, actor_id=user_id, event_id=voucher.id
  1006. )
  1007. db.commit()
  1008. db.refresh(settlement)
  1009. return settlement
  1010. def get_approval_flow(db: Session, settlement_id: str) -> List[Dict[str, Any]]:
  1011. rows = (
  1012. db.query(PlFreightSettlementApproval)
  1013. .filter(
  1014. PlFreightSettlementApproval.settlement_id == settlement_id,
  1015. PlFreightSettlementApproval.del_flag == 0,
  1016. )
  1017. .order_by(PlFreightSettlementApproval.step.asc(), PlFreightSettlementApproval.create_time.asc())
  1018. .all()
  1019. )
  1020. return [_approval_to_dict(r) for r in rows]
  1021. def get_approval_history(db: Session, settlement_id: str) -> List[Dict[str, Any]]:
  1022. """返回全部已完成的审批动作(含撤回/重提前的历史轮次),按时间正序。"""
  1023. rows = (
  1024. db.query(PlFreightSettlementApproval)
  1025. .filter(
  1026. PlFreightSettlementApproval.settlement_id == settlement_id,
  1027. PlFreightSettlementApproval.approval_result.in_(["approved", "rejected"]),
  1028. )
  1029. .order_by(
  1030. PlFreightSettlementApproval.approval_time.asc(),
  1031. PlFreightSettlementApproval.create_time.asc(),
  1032. )
  1033. .all()
  1034. )
  1035. return [_approval_to_dict(r) for r in rows]
  1036. def _current_approver_name(db: Session, settlement_id: str) -> Optional[str]:
  1037. row = (
  1038. db.query(PlFreightSettlementApproval)
  1039. .filter(
  1040. PlFreightSettlementApproval.settlement_id == settlement_id,
  1041. PlFreightSettlementApproval.approval_result == "pending",
  1042. PlFreightSettlementApproval.is_current == 1,
  1043. PlFreightSettlementApproval.del_flag == 0,
  1044. )
  1045. .order_by(PlFreightSettlementApproval.step.asc())
  1046. .first()
  1047. )
  1048. return row.approver_name if row else None
  1049. def _current_approval_id(db: Session, settlement_id: str, user_id: str) -> Optional[str]:
  1050. if not can_user_approve_settlement(db, settlement_id, user_id):
  1051. return None
  1052. row = (
  1053. db.query(PlFreightSettlementApproval)
  1054. .filter(
  1055. PlFreightSettlementApproval.settlement_id == settlement_id,
  1056. PlFreightSettlementApproval.approver_id == user_id,
  1057. PlFreightSettlementApproval.approval_result == "pending",
  1058. PlFreightSettlementApproval.is_current == 1,
  1059. PlFreightSettlementApproval.del_flag == 0,
  1060. )
  1061. .first()
  1062. )
  1063. return row.id if row else None
  1064. def _last_approval_time(db: Session, settlement_id: str) -> Optional[datetime]:
  1065. row = (
  1066. db.query(PlFreightSettlementApproval)
  1067. .filter(
  1068. PlFreightSettlementApproval.settlement_id == settlement_id,
  1069. PlFreightSettlementApproval.approval_result == "approved",
  1070. PlFreightSettlementApproval.del_flag == 0,
  1071. )
  1072. .order_by(PlFreightSettlementApproval.approval_time.desc())
  1073. .first()
  1074. )
  1075. return row.approval_time if row else None
  1076. def enrich_settlement_list_item(
  1077. db: Session, settlement: PlFreightSettlement, user_id: str
  1078. ) -> Dict[str, Any]:
  1079. transport_ids = _get_transport_ids(db, settlement.id)
  1080. plans = _get_plans_for_transports(db, settlement.tenant_no, transport_ids)
  1081. plates = [p.license_plate for p in plans if p.license_plate]
  1082. if not plates:
  1083. transports = (
  1084. db.query(PlTransport)
  1085. .filter(PlTransport.id.in_(transport_ids), PlTransport.del_flag == 0)
  1086. .all()
  1087. if transport_ids
  1088. else []
  1089. )
  1090. plates = [t.license_plate for t in transports if t.license_plate]
  1091. voucher = (
  1092. db.query(PlRemittanceVoucher)
  1093. .filter(
  1094. PlRemittanceVoucher.settlement_id == settlement.id,
  1095. PlRemittanceVoucher.del_flag == 0,
  1096. )
  1097. .order_by(PlRemittanceVoucher.create_time.desc())
  1098. .first()
  1099. )
  1100. submitter = _resolve_user_display_name(db, settlement.create_by or "")
  1101. item = {
  1102. "id": settlement.id,
  1103. "settlementNo": settlement.settlement_no,
  1104. "totalFreight": settlement.total_freight,
  1105. "invoiceAmount": settlement.invoice_amount,
  1106. "invoiceNo": settlement.invoice_no,
  1107. "invoiceDate": settlement.invoice_date,
  1108. "invoiceAttachmentUrls": settlement.invoice_attachment_urls,
  1109. "settlementStatus": settlement.settlement_status,
  1110. "remark": settlement.remark,
  1111. "tenantNo": settlement.tenant_no,
  1112. "orderNum": settlement.order_num,
  1113. "createBy": settlement.create_by,
  1114. "createDeptId": settlement.create_dept_id,
  1115. "createTime": settlement.create_time,
  1116. "updateBy": settlement.update_by,
  1117. "updateTime": settlement.update_time,
  1118. "delFlag": settlement.del_flag,
  1119. "planCount": len(plans),
  1120. "licensePlateSummary": "、".join(plates[:3]) + ("…" if len(plates) > 3 else ""),
  1121. "currentApproverName": _current_approver_name(db, settlement.id),
  1122. "canApprove": can_user_approve_settlement(db, settlement.id, user_id),
  1123. "canWithdraw": settlement.settlement_status == "approving" and settlement.create_by == user_id,
  1124. "currentApprovalId": _current_approval_id(db, settlement.id, user_id),
  1125. "approvalFinishedTime": _last_approval_time(db, settlement.id),
  1126. "disburseTime": voucher.create_time if voucher else None,
  1127. "disburseAmount": voucher.remittance_amount if voucher else None,
  1128. "submitterName": submitter or None,
  1129. }
  1130. return item
  1131. def _split_status_csv(raw: Optional[str]) -> List[str]:
  1132. if not raw or not str(raw).strip():
  1133. return []
  1134. return [s.strip() for s in str(raw).split(",") if s.strip()]
  1135. def list_settlements_enriched(
  1136. db: Session,
  1137. *,
  1138. tenant_no: str,
  1139. user_id: str,
  1140. user=None,
  1141. q: Optional[str] = None,
  1142. carrier_id: Optional[str] = None,
  1143. settlement_no: Optional[str] = None,
  1144. settlement_status: Optional[str] = None,
  1145. settlement_status_in: Optional[str] = None,
  1146. tab: Optional[str] = None,
  1147. ids: Optional[List[str]] = None,
  1148. page: int = 1,
  1149. page_size: int = 20,
  1150. max_page_size: Optional[int] = None,
  1151. total_freight_op: Optional[str] = None,
  1152. total_freight: Optional[str] = None,
  1153. total_freight_min: Optional[str] = None,
  1154. total_freight_max: Optional[str] = None,
  1155. create_time_from=None,
  1156. create_time_to=None,
  1157. submitter_name: Optional[str] = None,
  1158. current_approver_name: Optional[str] = None,
  1159. approval_finished_time_from=None,
  1160. approval_finished_time_to=None,
  1161. disburse_amount_op: Optional[str] = None,
  1162. disburse_amount: Optional[str] = None,
  1163. disburse_amount_min: Optional[str] = None,
  1164. disburse_amount_max: Optional[str] = None,
  1165. disburse_time_from=None,
  1166. disburse_time_to=None,
  1167. ) -> Tuple[int, List[Dict[str, Any]]]:
  1168. _sync_disbursed_settlements(db, tenant_no)
  1169. query = db.query(PlFreightSettlement).filter(
  1170. PlFreightSettlement.tenant_no == tenant_no,
  1171. PlFreightSettlement.del_flag == 0,
  1172. )
  1173. selected_statuses = _split_status_csv(settlement_status_in)
  1174. if not selected_statuses and settlement_status and str(settlement_status).strip():
  1175. selected_statuses = [str(settlement_status).strip()]
  1176. if tab and tab in TAB_STATUS_MAP:
  1177. tab_statuses = list(TAB_STATUS_MAP[tab])
  1178. if selected_statuses:
  1179. intersected = [s for s in selected_statuses if s in tab_statuses]
  1180. if not intersected:
  1181. query = query.filter(false())
  1182. else:
  1183. query = query.filter(PlFreightSettlement.settlement_status.in_(intersected))
  1184. else:
  1185. query = query.filter(PlFreightSettlement.settlement_status.in_(tab_statuses))
  1186. elif selected_statuses:
  1187. query = query.filter(PlFreightSettlement.settlement_status.in_(selected_statuses))
  1188. from app.services.pl.freight_list_filters import build_settlement_column_filters
  1189. column_filters = build_settlement_column_filters(
  1190. settlement_no=settlement_no,
  1191. total_freight_op=total_freight_op,
  1192. total_freight=total_freight,
  1193. total_freight_min=total_freight_min,
  1194. total_freight_max=total_freight_max,
  1195. create_time_from=create_time_from,
  1196. create_time_to=create_time_to,
  1197. submitter_name=submitter_name,
  1198. current_approver_name=current_approver_name,
  1199. approval_finished_time_from=approval_finished_time_from,
  1200. approval_finished_time_to=approval_finished_time_to,
  1201. disburse_amount_op=disburse_amount_op,
  1202. disburse_amount=disburse_amount,
  1203. disburse_amount_min=disburse_amount_min,
  1204. disburse_amount_max=disburse_amount_max,
  1205. disburse_time_from=disburse_time_from,
  1206. disburse_time_to=disburse_time_to,
  1207. )
  1208. if column_filters:
  1209. query = query.filter(*column_filters)
  1210. if q:
  1211. query = query.filter(PlFreightSettlement.settlement_no.contains(q))
  1212. if carrier_id:
  1213. carrier_settlement_ids = (
  1214. db.query(PlFreightSettlementTransport.settlement_id)
  1215. .join(PlTransport, PlTransport.id == PlFreightSettlementTransport.transport_id)
  1216. .filter(
  1217. PlFreightSettlementTransport.tenant_no == tenant_no,
  1218. PlFreightSettlementTransport.del_flag == 0,
  1219. PlTransport.del_flag == 0,
  1220. PlTransport.carrier_id == carrier_id,
  1221. )
  1222. .distinct()
  1223. )
  1224. query = query.filter(PlFreightSettlement.id.in_(carrier_settlement_ids))
  1225. if ids:
  1226. query = query.filter(PlFreightSettlement.id.in_(ids))
  1227. if user and tab != "approval_pending":
  1228. query = apply_pl_scope(query, PlFreightSettlement, user, db, tenant_no)
  1229. total = query.count()
  1230. page = max(1, page)
  1231. cap = int(max_page_size) if max_page_size else 500
  1232. page_size = min(max(1, page_size), cap)
  1233. rows = (
  1234. query.order_by(PlFreightSettlement.create_time.desc())
  1235. .offset((page - 1) * page_size)
  1236. .limit(page_size)
  1237. .all()
  1238. )
  1239. status_fixed = False
  1240. freight_fixed = False
  1241. for row in rows:
  1242. if _sync_settlement_status_if_approval_complete(db, row):
  1243. status_fixed = True
  1244. if _sync_settlement_status_if_disbursed(db, row):
  1245. status_fixed = True
  1246. if refresh_draft_settlement_freight(db, row, user_id=user_id):
  1247. freight_fixed = True
  1248. if status_fixed or freight_fixed:
  1249. db.commit()
  1250. for row in rows:
  1251. db.refresh(row)
  1252. if tab == "bills":
  1253. bills_statuses = set(TAB_STATUS_MAP["bills"])
  1254. rows = [r for r in rows if r.settlement_status in bills_statuses]
  1255. return total, [enrich_settlement_list_item(db, r, user_id) for r in rows]
  1256. def get_settlement_detail(
  1257. db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
  1258. ) -> Dict[str, Any]:
  1259. settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
  1260. if _sync_settlement_status_if_approval_complete(db, settlement):
  1261. db.commit()
  1262. db.refresh(settlement)
  1263. if _sync_settlement_status_if_disbursed(db, settlement):
  1264. db.commit()
  1265. db.refresh(settlement)
  1266. if refresh_draft_settlement_freight(db, settlement, user_id=user_id):
  1267. db.commit()
  1268. db.refresh(settlement)
  1269. base = enrich_settlement_list_item(db, settlement, user_id)
  1270. transport_ids = _get_transport_ids(db, settlement_id)
  1271. transports = (
  1272. db.query(PlTransport)
  1273. .filter(PlTransport.id.in_(transport_ids), PlTransport.del_flag == 0)
  1274. .all()
  1275. if transport_ids
  1276. else []
  1277. )
  1278. plans = _get_plans_for_transports(db, tenant_no, transport_ids)
  1279. plan_summaries = [
  1280. {
  1281. "id": p.id,
  1282. "planNo": p.plan_no,
  1283. "planDate": p.plan_date,
  1284. "licensePlate": p.license_plate,
  1285. "freightAmount": p.freight_amount,
  1286. "freightRemark": p.freight_remark,
  1287. }
  1288. for p in plans
  1289. ]
  1290. vouchers = (
  1291. db.query(PlRemittanceVoucher)
  1292. .filter(
  1293. PlRemittanceVoucher.settlement_id == settlement_id,
  1294. PlRemittanceVoucher.del_flag == 0,
  1295. )
  1296. .order_by(PlRemittanceVoucher.create_time.desc())
  1297. .all()
  1298. )
  1299. transport_payload = []
  1300. for t in transports:
  1301. transport_payload.append(
  1302. {
  1303. "id": t.id,
  1304. "transportNo": t.transport_no,
  1305. "licensePlate": t.license_plate,
  1306. "driverName": t.driver_name,
  1307. "carrierName": t.carrier_name,
  1308. "transportMedium": t.transport_medium,
  1309. "startPoint": t.start_point,
  1310. "endPoint": t.end_point,
  1311. "freightAmount": t.freight_amount,
  1312. "transportStatus": t.transport_status,
  1313. }
  1314. )
  1315. voucher_payload = []
  1316. for v in vouchers:
  1317. voucher_payload.append(
  1318. {
  1319. "id": v.id,
  1320. "settlementId": v.settlement_id,
  1321. "remittanceDate": v.remittance_date,
  1322. "remittanceAmount": v.remittance_amount,
  1323. "voucherUrls": v.voucher_urls,
  1324. "remark": v.remark,
  1325. "createBy": v.create_by,
  1326. "createTime": v.create_time,
  1327. }
  1328. )
  1329. base["transports"] = transport_payload
  1330. base["plans"] = plan_summaries
  1331. base["approvalFlow"] = get_approval_flow(db, settlement_id)
  1332. base["approvalHistory"] = get_approval_history(db, settlement_id)
  1333. base["vouchers"] = voucher_payload
  1334. return base
  1335. def _validate_transports_for_settlement_update(
  1336. db: Session,
  1337. *,
  1338. tenant_no: str,
  1339. settlement_id: str,
  1340. transport_ids: List[str],
  1341. ) -> List[PlTransport]:
  1342. cleaned = list(dict.fromkeys([tid.strip() for tid in transport_ids if (tid or "").strip()]))
  1343. if not cleaned:
  1344. raise HTTPException(status_code=400, detail="至少保留一条关联运输")
  1345. transports = (
  1346. db.query(PlTransport)
  1347. .filter(
  1348. PlTransport.id.in_(cleaned),
  1349. PlTransport.tenant_no == tenant_no,
  1350. PlTransport.del_flag == 0,
  1351. )
  1352. .all()
  1353. )
  1354. if len(transports) != len(cleaned):
  1355. raise HTTPException(status_code=400, detail="存在无效的运输单")
  1356. transport_map = {t.id: t for t in transports}
  1357. current_ids = set(_get_transport_ids(db, settlement_id))
  1358. added_ids = [tid for tid in cleaned if tid not in current_ids]
  1359. if added_ids:
  1360. occupied = {r[0] for r in _occupied_transport_ids_subquery(db, tenant_no).all()}
  1361. conflict = [tid for tid in added_ids if tid in occupied]
  1362. if conflict:
  1363. raise HTTPException(status_code=400, detail="所选运输单已纳入其他结算单")
  1364. plans = (
  1365. db.query(PlPlan)
  1366. .filter(
  1367. PlPlan.tenant_no == tenant_no,
  1368. PlPlan.del_flag == 0,
  1369. PlPlan.transport_id.in_(added_ids),
  1370. )
  1371. .all()
  1372. )
  1373. plan_by_transport = {p.transport_id: p for p in plans if p.transport_id}
  1374. for tid in added_ids:
  1375. plan = plan_by_transport.get(tid)
  1376. if not plan:
  1377. raise HTTPException(status_code=400, detail="存在未关联计划的运输单")
  1378. if plan.archive_status != "archived":
  1379. raise HTTPException(status_code=400, detail=f"计划 {plan.plan_no} 未归档,不能结算")
  1380. if int(plan.has_freight or 0) != 1:
  1381. raise HTTPException(status_code=400, detail=f"计划 {plan.plan_no} 无运输费用")
  1382. if plan.follow_status == "cancelled":
  1383. raise HTTPException(status_code=400, detail=f"计划 {plan.plan_no} 已作废")
  1384. amount = Decimal(str(transport_map[tid].freight_amount or 0))
  1385. if amount <= 0:
  1386. raise HTTPException(status_code=400, detail=f"运输单 {transport_map[tid].transport_no} 运费无效")
  1387. return [transport_map[tid] for tid in cleaned]
  1388. def _sync_settlement_transport_links(
  1389. db: Session,
  1390. *,
  1391. tenant_no: str,
  1392. settlement_id: str,
  1393. transport_ids: List[str],
  1394. transports: List[PlTransport],
  1395. user_id: str,
  1396. dept_id: str,
  1397. ) -> None:
  1398. cleaned = list(dict.fromkeys([tid.strip() for tid in transport_ids if (tid or "").strip()]))
  1399. transport_map = {t.id: t for t in transports}
  1400. now = datetime.now()
  1401. current_links = (
  1402. db.query(PlFreightSettlementTransport)
  1403. .filter(
  1404. PlFreightSettlementTransport.settlement_id == settlement_id,
  1405. PlFreightSettlementTransport.del_flag == 0,
  1406. )
  1407. .all()
  1408. )
  1409. current_map = {link.transport_id: link for link in current_links}
  1410. new_set = set(cleaned)
  1411. for tid, link in current_map.items():
  1412. if tid not in new_set:
  1413. link.del_flag = 1
  1414. link.update_by = user_id
  1415. link.update_time = now
  1416. for tid in cleaned:
  1417. transport = transport_map[tid]
  1418. amount = transport.freight_amount
  1419. if tid in current_map:
  1420. link = current_map[tid]
  1421. if Decimal(str(link.freight_amount or 0)) != Decimal(str(amount or 0)):
  1422. link.freight_amount = amount
  1423. link.update_by = user_id
  1424. link.update_time = now
  1425. continue
  1426. db.add(
  1427. PlFreightSettlementTransport(
  1428. id=generate_uuid(),
  1429. tenant_no=tenant_no,
  1430. settlement_id=settlement_id,
  1431. transport_id=tid,
  1432. freight_amount=amount,
  1433. create_by=user_id,
  1434. create_dept_id=dept_id or "",
  1435. )
  1436. )
  1437. _recalc_settlement_total_freight_from_links(db, settlement_id, user_id=user_id, now=now)
  1438. def update_settlement_draft(
  1439. db: Session,
  1440. *,
  1441. tenant_no: str,
  1442. settlement_id: str,
  1443. payload: FreightSettlementUpdate,
  1444. user_id: str,
  1445. user=None,
  1446. ) -> PlFreightSettlement:
  1447. settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
  1448. status = settlement.settlement_status
  1449. if status not in (*_DRAFT_SETTLEMENT_STATUSES, "approved"):
  1450. raise HTTPException(status_code=400, detail="当前状态不可编辑")
  1451. update_data = payload.model_dump(exclude_unset=True, by_alias=False)
  1452. transport_ids = update_data.pop("transport_ids", None)
  1453. if status == "approved":
  1454. if transport_ids is not None:
  1455. raise HTTPException(status_code=400, detail="当前状态不可修改关联运输")
  1456. allowed = {"invoice_amount", "invoice_no", "invoice_date", "invoice_attachment_urls", "remark"}
  1457. update_data = {k: v for k, v in update_data.items() if k in allowed}
  1458. if not update_data:
  1459. raise HTTPException(status_code=400, detail="无有效更新字段")
  1460. elif status in _DRAFT_SETTLEMENT_STATUSES and transport_ids is not None:
  1461. dept_id = getattr(user, "dept_id", "") if user else ""
  1462. transports = _validate_transports_for_settlement_update(
  1463. db,
  1464. tenant_no=tenant_no,
  1465. settlement_id=settlement_id,
  1466. transport_ids=transport_ids,
  1467. )
  1468. _sync_settlement_transport_links(
  1469. db,
  1470. tenant_no=tenant_no,
  1471. settlement_id=settlement_id,
  1472. transport_ids=transport_ids,
  1473. transports=transports,
  1474. user_id=user_id,
  1475. dept_id=dept_id,
  1476. )
  1477. for key, value in update_data.items():
  1478. setattr(settlement, key, value)
  1479. settlement.update_by = user_id
  1480. db.commit()
  1481. db.refresh(settlement)
  1482. return settlement