freight.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. """运输计划管理 - 运费结算 API"""
  2. from datetime import date, datetime
  3. from typing import Any, List, Optional
  4. from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
  5. from pydantic import BaseModel, ConfigDict, Field
  6. from sqlalchemy.orm import Session
  7. from app.core.database import get_db
  8. from app.core.db_auth import DbUserContext, get_db_current_user
  9. from app.core.tenant_context import get_tenant_no
  10. from app.schemas.pl.cancel_reason import CancelReasonBody
  11. from app.utils.excel_export import (
  12. EXPORT_MAX_ROWS,
  13. ExportColumn,
  14. ExportValidationError,
  15. export_rows_to_xlsx_response,
  16. filter_export_columns,
  17. )
  18. from app.models.pl_models import PlFreightSettlement, PlPlan, PlTransport
  19. from app.schemas.pl.freight import (
  20. FreightSettlementApprovalActionBody,
  21. FreightSettlementByTransportRead,
  22. FreightSettlementCreate,
  23. FreightSettlementDisburseBody,
  24. FreightSettlementListResponse,
  25. FreightSettlementRead,
  26. FreightSettlementSubmitApprovalBody,
  27. FreightSettlementUpdate,
  28. FreightSettlementWithTransports,
  29. RemittanceVoucherRead,
  30. UnsettledPlanListResponse,
  31. )
  32. from app.services import pl_freight_settlement_service as freight_svc
  33. router = APIRouter(prefix="/freight", tags=["运输计划-运费结算"])
  34. def _generate_settlement_no(db: Session) -> str:
  35. today = date.today()
  36. prefix = f"YF{today.strftime('%Y%m%d')}"
  37. last = (
  38. db.query(PlFreightSettlement)
  39. .filter(
  40. PlFreightSettlement.settlement_no.like(f"{prefix}%"),
  41. PlFreightSettlement.del_flag == 0,
  42. )
  43. .order_by(PlFreightSettlement.settlement_no.desc())
  44. .first()
  45. )
  46. seq = int(last.settlement_no[-2:]) + 1 if last else 1
  47. return f"{prefix}{seq:02d}"
  48. def _merge_query(request: Request, **known) -> dict:
  49. out = {k: v for k, v in known.items() if v is not None and v != ""}
  50. for k, v in request.query_params.multi_items():
  51. if k in ("page", "pageSize"):
  52. continue
  53. if k not in out and v not in (None, ""):
  54. out[k] = v
  55. return out
  56. def _param_get(params: dict, *keys: str) -> Any:
  57. for key in keys:
  58. if key in params and params[key] not in (None, ""):
  59. return params[key]
  60. return None
  61. def _body_to_params(body: BaseModel) -> dict:
  62. data = body.model_dump(by_alias=True, exclude_none=True)
  63. for key in ("ids", "columns"):
  64. data.pop(key, None)
  65. extra = getattr(body, "model_extra", None) or {}
  66. for key, value in extra.items():
  67. if key not in data and value not in (None, ""):
  68. data[key] = value
  69. return data
  70. def _unsettled_column_filter_kwargs(params: dict) -> dict:
  71. return {
  72. "plan_no": _param_get(params, "planNo", "plan_no"),
  73. "plan_no_in": _param_get(params, "planNoIn", "plan_no_in"),
  74. "plan_type": _param_get(params, "planType", "plan_type"),
  75. "plan_type_in": _param_get(params, "planTypeIn", "plan_type_in"),
  76. "license_plate": _param_get(params, "licensePlate", "license_plate"),
  77. "license_plate_in": _param_get(params, "licensePlateIn", "license_plate_in"),
  78. "transport_company": _param_get(params, "transportCompany", "transport_company"),
  79. "transport_company_in": _param_get(params, "transportCompanyIn", "transport_company_in"),
  80. "freight_amount_op": _param_get(params, "freightAmountOp", "freight_amount_op"),
  81. "freight_amount": _param_get(params, "freightAmount", "freight_amount"),
  82. "freight_amount_min": _param_get(params, "freightAmountMin", "freight_amount_min"),
  83. "freight_amount_max": _param_get(params, "freightAmountMax", "freight_amount_max"),
  84. "freight_type": _param_get(params, "freightType", "freight_type"),
  85. "freight_type_in": _param_get(params, "freightTypeIn", "freight_type_in"),
  86. "transport_qty_op": _param_get(params, "transportQtyOp", "transport_qty_op"),
  87. "transport_qty": _param_get(params, "transportQty", "transport_qty"),
  88. "transport_qty_min": _param_get(params, "transportQtyMin", "transport_qty_min"),
  89. "transport_qty_max": _param_get(params, "transportQtyMax", "transport_qty_max"),
  90. "has_invoice": _param_get(params, "hasInvoice", "has_invoice"),
  91. "has_invoice_in": _param_get(params, "hasInvoiceIn", "has_invoice_in"),
  92. "freight_remark": _param_get(params, "freightRemark", "freight_remark"),
  93. "freight_remark_in": _param_get(params, "freightRemarkIn", "freight_remark_in"),
  94. "plan_date_from": _param_get(params, "planDateFrom", "plan_date_from"),
  95. "plan_date_to": _param_get(params, "planDateTo", "plan_date_to"),
  96. "freight_price_op": _param_get(params, "freightPriceOp", "freight_price_op"),
  97. "freight_price": _param_get(params, "freightPrice", "freight_price"),
  98. "freight_price_min": _param_get(params, "freightPriceMin", "freight_price_min"),
  99. "freight_price_max": _param_get(params, "freightPriceMax", "freight_price_max"),
  100. "archive_time_from": _param_get(params, "archiveTimeFrom", "archive_time_from"),
  101. "archive_time_to": _param_get(params, "archiveTimeTo", "archive_time_to"),
  102. "load_customers": _param_get(params, "loadCustomers", "load_customers"),
  103. "delivery_customers": _param_get(
  104. params, "deliveryCustomers", "delivery_customers"
  105. ),
  106. }
  107. def _settlement_column_filter_kwargs(params: dict) -> dict:
  108. return {
  109. "settlement_no": _param_get(params, "settlementNo", "settlement_no"),
  110. "total_freight_op": _param_get(params, "totalFreightOp", "total_freight_op"),
  111. "total_freight": _param_get(params, "totalFreight", "total_freight"),
  112. "total_freight_min": _param_get(params, "totalFreightMin", "total_freight_min"),
  113. "total_freight_max": _param_get(params, "totalFreightMax", "total_freight_max"),
  114. "create_time_from": _param_get(params, "createTimeFrom", "create_time_from"),
  115. "create_time_to": _param_get(params, "createTimeTo", "create_time_to"),
  116. "submitter_name": _param_get(params, "submitterName", "submitter_name"),
  117. "current_approver_name": _param_get(
  118. params, "currentApproverName", "current_approver_name"
  119. ),
  120. "approval_finished_time_from": _param_get(
  121. params, "approvalFinishedTimeFrom", "approval_finished_time_from"
  122. ),
  123. "approval_finished_time_to": _param_get(
  124. params, "approvalFinishedTimeTo", "approval_finished_time_to"
  125. ),
  126. "disburse_amount_op": _param_get(
  127. params, "disburseAmountOp", "disburse_amount_op"
  128. ),
  129. "disburse_amount": _param_get(params, "disburseAmount", "disburse_amount"),
  130. "disburse_amount_min": _param_get(
  131. params, "disburseAmountMin", "disburse_amount_min"
  132. ),
  133. "disburse_amount_max": _param_get(
  134. params, "disburseAmountMax", "disburse_amount_max"
  135. ),
  136. "disburse_time_from": _param_get(
  137. params, "disburseTimeFrom", "disburse_time_from"
  138. ),
  139. "disburse_time_to": _param_get(params, "disburseTimeTo", "disburse_time_to"),
  140. }
  141. class FreightColumnFacetBody(BaseModel):
  142. """运费未结算列头可选值(去重;可附带列表上下文)"""
  143. model_config = ConfigDict(extra="allow")
  144. field: str = Field(..., description="planNo | licensePlate | transportCompany | freightRemark")
  145. keyword: Optional[str] = Field(default=None, description="可选值关键字")
  146. limit: Optional[int] = Field(default=500, ge=1, le=500, description="返回条数上限")
  147. @router.post("/column-facet-values")
  148. def freight_column_facet_values(
  149. body: FreightColumnFacetBody,
  150. db: Session = Depends(get_db),
  151. user: DbUserContext = Depends(get_db_current_user),
  152. ):
  153. """运费未结算列头可选值:在未结算列表上下文(排除本列)下去重。"""
  154. from sqlalchemy import func as sa_func
  155. from app.core.tenant_context import get_tenant_no
  156. from app.services.pl.facet_context import strip_simple_facet_self_params
  157. from app.services.pl.freight_list_filters import build_unsettled_plan_column_filters
  158. from app.services.pl_freight_settlement_service import (
  159. _occupied_transport_ids_subquery,
  160. unsettled_plans_q_filter,
  161. )
  162. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  163. lim = min(max(1, int(body.limit or 500)), 500)
  164. kw = (body.keyword or "").strip()
  165. field = str(body.field or "").strip()
  166. params = strip_simple_facet_self_params(_body_to_params(body), body.field)
  167. occupied = _occupied_transport_ids_subquery(db, tenant_no).subquery()
  168. base = (
  169. db.query(PlPlan.id)
  170. .join(PlTransport, PlTransport.id == PlPlan.transport_id)
  171. .filter(
  172. PlPlan.tenant_no == tenant_no,
  173. PlPlan.del_flag == 0,
  174. PlPlan.archive_status == "archived",
  175. PlPlan.has_freight == 1,
  176. PlPlan.follow_status != "cancelled",
  177. PlPlan.transport_id.isnot(None),
  178. PlTransport.del_flag == 0,
  179. PlTransport.freight_amount > 0,
  180. ~PlPlan.transport_id.in_(occupied),
  181. )
  182. )
  183. qv = _param_get(params, "q")
  184. if qv:
  185. base = base.filter(unsettled_plans_q_filter(str(qv)))
  186. carrier_id = _param_get(params, "carrierId", "carrier_id")
  187. if carrier_id:
  188. base = base.filter(PlTransport.carrier_id == carrier_id)
  189. column_filters = build_unsettled_plan_column_filters(
  190. **_unsettled_column_filter_kwargs(params)
  191. )
  192. if column_filters:
  193. base = base.filter(*column_filters)
  194. from app.services.pl_data_scope import apply_pl_scope
  195. base = apply_pl_scope(base, PlPlan, user, db, tenant_no)
  196. plan_id_sq = base.subquery()
  197. def _finish(rows):
  198. out = []
  199. for row in rows:
  200. value = str(row.value).strip() if row.value is not None else ""
  201. if not value:
  202. continue
  203. out.append({"value": value, "label": value, "count": int(row.cnt or 0)})
  204. return out
  205. if field == "transportCompany":
  206. col = PlTransport.carrier_name
  207. q = (
  208. db.query(col.label("value"), sa_func.count(PlPlan.id).label("cnt"))
  209. .select_from(PlPlan)
  210. .join(PlTransport, PlTransport.id == PlPlan.transport_id)
  211. .filter(
  212. PlPlan.id.in_(plan_id_sq),
  213. col.isnot(None),
  214. col != "",
  215. )
  216. )
  217. else:
  218. col_map = {
  219. "planNo": PlPlan.plan_no,
  220. "licensePlate": PlPlan.license_plate,
  221. "freightRemark": PlPlan.freight_remark,
  222. }
  223. col = col_map.get(field)
  224. if col is None:
  225. raise HTTPException(status_code=400, detail="不支持的列")
  226. q = (
  227. db.query(col.label("value"), sa_func.count(PlPlan.id).label("cnt"))
  228. .select_from(PlPlan)
  229. .filter(
  230. PlPlan.id.in_(plan_id_sq),
  231. col.isnot(None),
  232. col != "",
  233. )
  234. )
  235. if kw:
  236. q = q.filter(col.like(f"%{kw}%"))
  237. rows = q.group_by(col).order_by(col.asc()).limit(lim).all()
  238. return {"list": _finish(rows)}
  239. @router.get("/unsettled-plans", response_model=UnsettledPlanListResponse)
  240. def list_unsettled_plans(
  241. request: Request,
  242. q: Optional[str] = Query(default=None),
  243. carrier_id: Optional[str] = Query(default=None, alias="carrierId"),
  244. plan_no: Optional[str] = Query(default=None, alias="planNo"),
  245. plan_type: Optional[str] = Query(default=None, alias="planType"),
  246. plan_type_in: Optional[str] = Query(default=None, alias="planTypeIn"),
  247. license_plate: Optional[str] = Query(default=None, alias="licensePlate"),
  248. page: int = Query(default=1, ge=1),
  249. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  250. db: Session = Depends(get_db),
  251. user: DbUserContext = Depends(get_db_current_user),
  252. ) -> UnsettledPlanListResponse:
  253. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  254. params = _merge_query(
  255. request,
  256. q=q,
  257. carrierId=carrier_id,
  258. planNo=plan_no,
  259. planType=plan_type,
  260. planTypeIn=plan_type_in,
  261. licensePlate=license_plate,
  262. )
  263. total, items = freight_svc.list_unsettled_plans(
  264. db,
  265. tenant_no=tenant_no,
  266. user=user,
  267. q=_param_get(params, "q"),
  268. carrier_id=_param_get(params, "carrierId", "carrier_id"),
  269. **_unsettled_column_filter_kwargs(params),
  270. page=page,
  271. page_size=page_size,
  272. )
  273. return UnsettledPlanListResponse(total=total, list=items)
  274. @router.get("/", response_model=FreightSettlementListResponse)
  275. def list_settlements(
  276. request: Request,
  277. q: Optional[str] = Query(default=None),
  278. carrier_id: Optional[str] = Query(default=None, alias="carrierId"),
  279. settlement_no: Optional[str] = Query(default=None, alias="settlementNo"),
  280. settlement_status: Optional[str] = Query(default=None, alias="settlementStatus"),
  281. settlement_status_in: Optional[str] = Query(default=None, alias="settlementStatusIn"),
  282. tab: Optional[str] = Query(
  283. default=None,
  284. description="bills|approval_pending|approval_done|disburse|settled",
  285. ),
  286. page: int = Query(default=1, ge=1),
  287. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  288. db: Session = Depends(get_db),
  289. user: DbUserContext = Depends(get_db_current_user),
  290. ) -> FreightSettlementListResponse:
  291. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  292. params = _merge_query(
  293. request,
  294. q=q,
  295. carrierId=carrier_id,
  296. settlementNo=settlement_no,
  297. settlementStatus=settlement_status,
  298. settlementStatusIn=settlement_status_in,
  299. tab=tab,
  300. )
  301. total, items = freight_svc.list_settlements_enriched(
  302. db,
  303. tenant_no=tenant_no,
  304. user_id=user.id,
  305. user=user,
  306. q=_param_get(params, "q"),
  307. carrier_id=_param_get(params, "carrierId", "carrier_id"),
  308. settlement_status=_param_get(params, "settlementStatus", "settlement_status"),
  309. settlement_status_in=_param_get(
  310. params, "settlementStatusIn", "settlement_status_in"
  311. ),
  312. tab=_param_get(params, "tab"),
  313. page=page,
  314. page_size=page_size,
  315. **_settlement_column_filter_kwargs(params),
  316. )
  317. return FreightSettlementListResponse(total=total, list=items)
  318. UNSETTLED_EXPORT_COLUMNS = [
  319. ExportColumn(title="计划编号", field="planNo"),
  320. ExportColumn(title="计划日期", field="planDate"),
  321. ExportColumn(title="单据类型", field="planType"),
  322. ExportColumn(title="装货客户", field="loadCustomers"),
  323. ExportColumn(title="送货客户", field="deliveryCustomers"),
  324. ExportColumn(title="车牌号", field="licensePlate"),
  325. ExportColumn(title="承运商", field="transportCompany"),
  326. ExportColumn(title="运费(元)", field="freightAmount"),
  327. ExportColumn(title="运费计算方式", field="freightType"),
  328. ExportColumn(title="运输量", field="transportQty"),
  329. ExportColumn(title="运输单价", field="freightPrice"),
  330. ExportColumn(title="是否开票", field="hasInvoice"),
  331. ExportColumn(title="归档时间", field="archiveTime"),
  332. ]
  333. PLAN_TYPE_LABELS = {"direct": "直销单", "inbound": "入库单", "outbound": "出库单"}
  334. FREIGHT_TYPE_LABELS = {"by_weight": "按重量", "by_trip": "按趟次"}
  335. SETTLEMENT_STATUS_LABELS = {
  336. "pending": "草稿",
  337. "rejected": "已驳回",
  338. "approving": "审批中",
  339. "approved": "待发放",
  340. "confirmed": "已发放",
  341. "cancelled": "已作废",
  342. }
  343. class UnsettledExportBody(BaseModel):
  344. q: Optional[str] = None
  345. carrier_id: Optional[str] = Field(default=None, alias="carrierId")
  346. plan_no: Optional[str] = Field(default=None, alias="planNo")
  347. plan_type: Optional[str] = Field(default=None, alias="planType")
  348. plan_type_in: Optional[str] = Field(default=None, alias="planTypeIn")
  349. license_plate: Optional[str] = Field(default=None, alias="licensePlate")
  350. ids: Optional[List[str]] = None
  351. columns: Optional[List[str]] = None
  352. model_config = {"populate_by_name": True, "extra": "allow"}
  353. @router.post("/unsettled-plans/export")
  354. def export_unsettled_plans(
  355. body: UnsettledExportBody,
  356. db: Session = Depends(get_db),
  357. user: DbUserContext = Depends(get_db_current_user),
  358. ):
  359. try:
  360. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  361. params = _body_to_params(body)
  362. _, items = freight_svc.list_unsettled_plans(
  363. db,
  364. tenant_no=tenant_no,
  365. user=user,
  366. q=_param_get(params, "q"),
  367. carrier_id=_param_get(params, "carrierId", "carrier_id"),
  368. **_unsettled_column_filter_kwargs(params),
  369. ids=body.ids,
  370. page=1,
  371. page_size=EXPORT_MAX_ROWS,
  372. max_page_size=EXPORT_MAX_ROWS,
  373. )
  374. rows = []
  375. for item in items:
  376. data = dict(item)
  377. data["deliveryCustomers"] = "\n".join(data.get("deliveryCustomers") or [])
  378. data["loadCustomers"] = "\n".join(data.get("loadCustomers") or [])
  379. data["planType"] = PLAN_TYPE_LABELS.get(str(data.get("planType") or ""), data.get("planType"))
  380. freight_type = str(data.get("freightType") or "")
  381. data["freightType"] = FREIGHT_TYPE_LABELS.get(freight_type, freight_type)
  382. data["hasInvoice"] = "是" if int(data.get("hasInvoice") or 0) == 1 else "否"
  383. for k, v in list(data.items()):
  384. if hasattr(v, "isoformat"):
  385. data[k] = str(v)[:19]
  386. rows.append(data)
  387. cols = filter_export_columns(UNSETTLED_EXPORT_COLUMNS, body.columns)
  388. date_str = datetime.now().strftime("%Y%m%d")
  389. return export_rows_to_xlsx_response(
  390. db, tenant_no=tenant_no, sheet_title="待结算计划", columns=cols, rows=rows,
  391. filename_stem=f"待结算计划_{date_str}",
  392. )
  393. except ExportValidationError as e:
  394. raise HTTPException(status_code=400, detail=str(e)) from e
  395. SETTLEMENT_EXPORT_COLUMNS = [
  396. ExportColumn(title="结算单号", field="settlementNo"),
  397. ExportColumn(title="运费合计(元)", field="totalFreight"),
  398. ExportColumn(title="关联计划数", field="planCount"),
  399. ExportColumn(title="车牌摘要", field="licensePlateSummary"),
  400. ExportColumn(title="结算状态", field="settlementStatus"),
  401. ExportColumn(title="发放金额(元)", field="disburseAmount"),
  402. ExportColumn(title="发放时间", field="disburseTime"),
  403. ExportColumn(title="当前审批人", field="currentApproverName"),
  404. ExportColumn(title="提交人", field="submitterName"),
  405. ExportColumn(title="创建时间", field="createTime"),
  406. ExportColumn(title="审批完成时间", field="approvalFinishedTime"),
  407. ]
  408. class SettlementExportBody(BaseModel):
  409. q: Optional[str] = None
  410. carrier_id: Optional[str] = Field(default=None, alias="carrierId")
  411. settlement_no: Optional[str] = Field(default=None, alias="settlementNo")
  412. settlement_status: Optional[str] = Field(default=None, alias="settlementStatus")
  413. settlement_status_in: Optional[str] = Field(default=None, alias="settlementStatusIn")
  414. tab: Optional[str] = None
  415. ids: Optional[List[str]] = None
  416. columns: Optional[List[str]] = None
  417. model_config = {"populate_by_name": True, "extra": "allow"}
  418. @router.post("/export")
  419. def export_settlements(
  420. body: SettlementExportBody,
  421. db: Session = Depends(get_db),
  422. user: DbUserContext = Depends(get_db_current_user),
  423. ):
  424. try:
  425. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  426. params = _body_to_params(body)
  427. _, items = freight_svc.list_settlements_enriched(
  428. db,
  429. tenant_no=tenant_no,
  430. user_id=user.id,
  431. user=user,
  432. q=_param_get(params, "q"),
  433. carrier_id=_param_get(params, "carrierId", "carrier_id"),
  434. settlement_status=_param_get(params, "settlementStatus", "settlement_status"),
  435. settlement_status_in=_param_get(
  436. params, "settlementStatusIn", "settlement_status_in"
  437. ),
  438. tab=_param_get(params, "tab"),
  439. ids=body.ids,
  440. page=1,
  441. page_size=EXPORT_MAX_ROWS,
  442. max_page_size=EXPORT_MAX_ROWS,
  443. **_settlement_column_filter_kwargs(params),
  444. )
  445. rows = []
  446. for item in items:
  447. data = dict(item)
  448. stv = data.get("settlementStatus")
  449. if stv is not None:
  450. data["settlementStatus"] = SETTLEMENT_STATUS_LABELS.get(str(stv), stv)
  451. for k, v in list(data.items()):
  452. if hasattr(v, "isoformat"):
  453. data[k] = str(v)[:19]
  454. rows.append(data)
  455. cols = filter_export_columns(SETTLEMENT_EXPORT_COLUMNS, body.columns)
  456. date_str = datetime.now().strftime("%Y%m%d")
  457. return export_rows_to_xlsx_response(
  458. db, tenant_no=tenant_no, sheet_title="运费结算", columns=cols, rows=rows,
  459. filename_stem=f"运费结算_{date_str}",
  460. )
  461. except ExportValidationError as e:
  462. raise HTTPException(status_code=400, detail=str(e)) from e
  463. @router.get("/by-transport/{transport_id}", response_model=Optional[FreightSettlementByTransportRead])
  464. def get_settlement_by_transport(
  465. transport_id: str,
  466. db: Session = Depends(get_db),
  467. user: DbUserContext = Depends(get_db_current_user),
  468. ) -> Optional[FreightSettlementByTransportRead]:
  469. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  470. detail = freight_svc.get_settlement_by_transport_id(
  471. db, tenant_no=tenant_no, transport_id=transport_id, user=user
  472. )
  473. if not detail:
  474. return None
  475. return FreightSettlementByTransportRead.model_validate(detail)
  476. @router.post("/", response_model=FreightSettlementRead, status_code=status.HTTP_201_CREATED)
  477. def create_settlement(
  478. payload: FreightSettlementCreate,
  479. db: Session = Depends(get_db),
  480. user: DbUserContext = Depends(get_db_current_user),
  481. ) -> FreightSettlementRead:
  482. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  483. settlement = freight_svc.create_settlement(
  484. db,
  485. tenant_no=tenant_no,
  486. plan_ids=payload.plan_ids,
  487. transport_ids=payload.transport_ids,
  488. user_id=user.id,
  489. dept_id=user.dept_id,
  490. settlement_no=_generate_settlement_no(db),
  491. )
  492. return settlement
  493. @router.get("/{id}", response_model=FreightSettlementWithTransports)
  494. def get_settlement(
  495. id: str,
  496. db: Session = Depends(get_db),
  497. user: DbUserContext = Depends(get_db_current_user),
  498. ) -> FreightSettlementWithTransports:
  499. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  500. detail = freight_svc.get_settlement_detail(
  501. db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user
  502. )
  503. return FreightSettlementWithTransports.model_validate(detail)
  504. @router.put("/{id}", response_model=FreightSettlementRead)
  505. def update_settlement(
  506. id: str,
  507. payload: FreightSettlementUpdate,
  508. db: Session = Depends(get_db),
  509. user: DbUserContext = Depends(get_db_current_user),
  510. ) -> FreightSettlementRead:
  511. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  512. return freight_svc.update_settlement_draft(
  513. db,
  514. tenant_no=tenant_no,
  515. settlement_id=id,
  516. payload=payload,
  517. user_id=user.id,
  518. user=user,
  519. )
  520. @router.post("/{id}/cancel", response_model=FreightSettlementRead)
  521. def cancel_settlement(
  522. id: str,
  523. body: Optional[CancelReasonBody] = Body(default=None),
  524. db: Session = Depends(get_db),
  525. user: DbUserContext = Depends(get_db_current_user),
  526. ) -> FreightSettlementRead:
  527. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  528. return freight_svc.cancel_draft_settlement(
  529. db,
  530. tenant_no=tenant_no,
  531. settlement_id=id,
  532. user_id=user.id,
  533. user=user,
  534. cancel_reason=body.cancel_reason if body else None,
  535. )
  536. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  537. def delete_settlement(
  538. id: str,
  539. db: Session = Depends(get_db),
  540. user: DbUserContext = Depends(get_db_current_user),
  541. ) -> None:
  542. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  543. freight_svc.delete_draft_settlement(
  544. db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user
  545. )
  546. @router.post("/{id}/submit-approval", response_model=FreightSettlementRead)
  547. def submit_settlement_approval(
  548. id: str,
  549. payload: FreightSettlementSubmitApprovalBody,
  550. db: Session = Depends(get_db),
  551. user: DbUserContext = Depends(get_db_current_user),
  552. ) -> FreightSettlementRead:
  553. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  554. return freight_svc.submit_settlement_approval(
  555. db,
  556. tenant_no=tenant_no,
  557. settlement_id=id,
  558. body=payload,
  559. user_id=user.id,
  560. dept_id=user.dept_id,
  561. user=user,
  562. )
  563. @router.post("/approval/{approval_id}", response_model=FreightSettlementRead)
  564. def process_settlement_approval(
  565. approval_id: str,
  566. payload: FreightSettlementApprovalActionBody,
  567. db: Session = Depends(get_db),
  568. user: DbUserContext = Depends(get_db_current_user),
  569. ) -> FreightSettlementRead:
  570. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  571. if payload.action not in ("approve", "reject"):
  572. raise HTTPException(status_code=400, detail="action 必须为 approve 或 reject")
  573. return freight_svc.process_settlement_approval(
  574. db,
  575. tenant_no=tenant_no,
  576. approval_id=approval_id,
  577. action=payload.action,
  578. comment=payload.comment,
  579. user_id=user.id,
  580. )
  581. @router.post("/{id}/withdraw-approval", response_model=FreightSettlementRead)
  582. def withdraw_settlement_approval(
  583. id: str,
  584. db: Session = Depends(get_db),
  585. user: DbUserContext = Depends(get_db_current_user),
  586. ) -> FreightSettlementRead:
  587. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  588. return freight_svc.withdraw_settlement_approval(
  589. db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user
  590. )
  591. @router.get("/{id}/approval-flow")
  592. def get_settlement_approval_flow(
  593. id: str,
  594. db: Session = Depends(get_db),
  595. user: DbUserContext = Depends(get_db_current_user),
  596. ):
  597. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  598. freight_svc.get_settlement_row(db, tenant_no, id, user=user)
  599. return freight_svc.get_approval_flow(db, id)
  600. @router.post("/{id}/disburse", response_model=FreightSettlementRead)
  601. def disburse_settlement(
  602. id: str,
  603. payload: FreightSettlementDisburseBody,
  604. db: Session = Depends(get_db),
  605. user: DbUserContext = Depends(get_db_current_user),
  606. ) -> FreightSettlementRead:
  607. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  608. return freight_svc.disburse_settlement(
  609. db,
  610. tenant_no=tenant_no,
  611. settlement_id=id,
  612. body=payload,
  613. user_id=user.id,
  614. dept_id=user.dept_id,
  615. user=user,
  616. )
  617. # 向后兼容旧接口
  618. @router.post("/{id}/submit", response_model=FreightSettlementRead, deprecated=True)
  619. def submit_settlement_legacy(
  620. id: str,
  621. db: Session = Depends(get_db),
  622. user: DbUserContext = Depends(get_db_current_user),
  623. ) -> FreightSettlementRead:
  624. raise HTTPException(status_code=400, detail="请使用 submit-approval 接口并选择审批人")
  625. @router.post("/{id}/approve", response_model=FreightSettlementRead, deprecated=True)
  626. def approve_settlement_legacy(
  627. id: str,
  628. db: Session = Depends(get_db),
  629. user: DbUserContext = Depends(get_db_current_user),
  630. ) -> FreightSettlementRead:
  631. raise HTTPException(status_code=400, detail="请使用 approval/{approvalId} 接口审批")
  632. @router.get("/voucher/{settlement_id}", response_model=list[RemittanceVoucherRead])
  633. def list_vouchers(
  634. settlement_id: str,
  635. db: Session = Depends(get_db),
  636. user: DbUserContext = Depends(get_db_current_user),
  637. ) -> list[RemittanceVoucherRead]:
  638. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  639. freight_svc.get_settlement_row(db, tenant_no, settlement_id, user=user)
  640. detail = freight_svc.get_settlement_detail(
  641. db, tenant_no=tenant_no, settlement_id=settlement_id, user_id=user.id, user=user
  642. )
  643. return detail.get("vouchers") or []