base_data.py 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142
  1. """运输计划管理 - 基础数据 API(承运商、车辆、人员)"""
  2. from datetime import datetime
  3. from typing import List, Optional
  4. from fastapi import APIRouter, Depends, HTTPException, Query, status
  5. from pydantic import BaseModel, Field
  6. from sqlalchemy import case, func, or_
  7. from sqlalchemy.orm import Session
  8. from app.utils.excel_export import (
  9. EXPORT_MAX_ROWS,
  10. ExportColumn,
  11. ExportValidationError,
  12. export_rows_to_xlsx_response,
  13. filter_export_columns,
  14. )
  15. from app.core.database import get_db
  16. from app.core.db_auth import DbUserContext, get_db_current_user
  17. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  18. from app.core.tenant_context import get_tenant_no
  19. from app.core.utils import generate_uuid
  20. from app.models.pl_models import (
  21. PlCarrier,
  22. PlPersonnel,
  23. PlVehicle,
  24. PlVehicleDriver,
  25. PlVehiclePersonnel,
  26. )
  27. from app.schemas.pl import (
  28. CarrierAuditReject,
  29. CarrierCreate,
  30. CarrierListResponse,
  31. CarrierRead,
  32. CarrierUpdate,
  33. carrier_to_read,
  34. CertRead,
  35. DriverCreate,
  36. DriverListResponse,
  37. DriverRead,
  38. DriverUpdate,
  39. PersonnelCreate,
  40. PersonnelDetailRead,
  41. PersonnelListResponse,
  42. PersonnelRead,
  43. PersonnelUpdate,
  44. VehicleCreate,
  45. VehicleDetailRead,
  46. VehicleDriverCreate,
  47. VehicleListResponse,
  48. VehicleRead,
  49. VehicleUpdate,
  50. )
  51. from app.services.pl_cert_service import (
  52. certs_to_read,
  53. list_owner_certs,
  54. list_owner_certs_batch,
  55. nearest_expire_date,
  56. save_owner_certs,
  57. )
  58. from app.services.pl_cert_required_service import (
  59. validate_personnel_required_certs,
  60. validate_vehicle_save_certs,
  61. )
  62. from app.services.pl_carrier_service import carrier_eligible_for_transport, normalize_carrier_urls
  63. from app.services.pl_personnel_service import (
  64. load_vehicle_personnel_rows,
  65. load_vehicle_personnel_summaries,
  66. save_vehicle_personnel,
  67. )
  68. from app.schemas.pl.paste_recognize import PasteCommitBody, PasteCommitResult
  69. from app.services.pl_paste_recognize_service import commit_paste_recognize
  70. router = APIRouter(prefix="", tags=["运输计划-基础数据"])
  71. # =============================================================================
  72. # 承运商 API
  73. # =============================================================================
  74. @router.get("/carrier", response_model=CarrierListResponse)
  75. def list_carriers(
  76. q: Optional[str] = Query(default=None, description="模糊搜索"),
  77. status: Optional[str] = Query(default=None, description="状态"),
  78. audit_status: Optional[str] = Query(default=None, alias="auditStatus", description="审核状态"),
  79. page: int = Query(default=1, ge=1),
  80. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  81. db: Session = Depends(get_db),
  82. user: DbUserContext = Depends(get_db_current_user),
  83. ) -> CarrierListResponse:
  84. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  85. query = db.query(PlCarrier).filter(
  86. PlCarrier.tenant_no == tenant_no,
  87. PlCarrier.del_flag == 0,
  88. )
  89. if status:
  90. query = query.filter(PlCarrier.status == status)
  91. if audit_status:
  92. query = query.filter(PlCarrier.audit_status == audit_status)
  93. if q:
  94. query = query.filter(
  95. or_(
  96. func.lower(PlCarrier.carrier_name).contains(q.lower()),
  97. func.lower(PlCarrier.contact_name).contains(q.lower()),
  98. )
  99. )
  100. # 承运商为物流主数据,列表不做 create_by 数据范围过滤
  101. query = query.order_by(
  102. case((PlCarrier.order_num.is_(None), 1), else_=0).asc(),
  103. PlCarrier.create_time.desc(),
  104. )
  105. total = query.count()
  106. offset = (page - 1) * page_size
  107. rows = query.offset(offset).limit(page_size).all()
  108. return CarrierListResponse(
  109. total=total,
  110. list=[carrier_to_read(row) for row in rows],
  111. )
  112. CARRIER_EXPORT_COLUMNS = [
  113. ExportColumn(title="承运商名称", field="carrierName"),
  114. ExportColumn(title="联系人", field="contactName"),
  115. ExportColumn(title="联系电话", field="contactPhone"),
  116. ExportColumn(title="合作状态", field="statusLabel"),
  117. ExportColumn(title="资质提示", field="qualificationHint"),
  118. ExportColumn(title="协议有效期", field="transportAgreementExpireDate"),
  119. ExportColumn(title="备注", field="remark"),
  120. ]
  121. class CarrierExportBody(BaseModel):
  122. q: Optional[str] = None
  123. status: Optional[str] = None
  124. audit_status: Optional[str] = Field(default=None, alias="auditStatus")
  125. ids: Optional[List[str]] = None
  126. columns: Optional[List[str]] = None
  127. model_config = {"populate_by_name": True}
  128. @router.post("/carrier/export")
  129. def export_carriers(
  130. body: CarrierExportBody,
  131. db: Session = Depends(get_db),
  132. user: DbUserContext = Depends(get_db_current_user),
  133. ):
  134. """导出承运商列表(当前筛选全量,或 ids 已选)。"""
  135. try:
  136. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  137. query = db.query(PlCarrier).filter(
  138. PlCarrier.tenant_no == tenant_no,
  139. PlCarrier.del_flag == 0,
  140. )
  141. if body.status:
  142. query = query.filter(PlCarrier.status == body.status)
  143. if body.audit_status:
  144. query = query.filter(PlCarrier.audit_status == body.audit_status)
  145. if body.q:
  146. query = query.filter(
  147. or_(
  148. func.lower(PlCarrier.carrier_name).contains(body.q.lower()),
  149. func.lower(PlCarrier.contact_name).contains(body.q.lower()),
  150. )
  151. )
  152. if body.ids:
  153. query = query.filter(PlCarrier.id.in_(body.ids))
  154. query = query.order_by(
  155. case((PlCarrier.order_num.is_(None), 1), else_=0).asc(),
  156. PlCarrier.create_time.desc(),
  157. )
  158. rows = query.limit(EXPORT_MAX_ROWS + 1).all()
  159. items = []
  160. for row in rows:
  161. data = carrier_to_read(row).model_dump(by_alias=True)
  162. data["statusLabel"] = "合作中" if str(data.get("status")) == "1" else "已终止"
  163. if data.get("transportAgreementExpired"):
  164. data["qualificationHint"] = "协议已过期"
  165. elif data.get("qualificationComplete") is False:
  166. data["qualificationHint"] = "资质不全"
  167. else:
  168. data["qualificationHint"] = "资质齐全"
  169. items.append(data)
  170. requested = None
  171. if body.columns:
  172. requested = []
  173. for key in body.columns:
  174. if key in (
  175. "qualificationAttachments",
  176. "qualificationAttachment",
  177. "photoUrls",
  178. ):
  179. continue
  180. if key == "status":
  181. requested.append("statusLabel")
  182. elif key == "qualificationHint":
  183. requested.append("qualificationHint")
  184. else:
  185. requested.append(key)
  186. cols = filter_export_columns(CARRIER_EXPORT_COLUMNS, requested)
  187. date_str = datetime.now().strftime("%Y%m%d")
  188. return export_rows_to_xlsx_response(
  189. db,
  190. tenant_no=tenant_no,
  191. sheet_title="承运商列表",
  192. columns=cols,
  193. rows=items,
  194. filename_stem=f"承运商列表_{date_str}",
  195. )
  196. except ExportValidationError as e:
  197. raise HTTPException(status_code=400, detail=str(e)) from e
  198. @router.post("/carrier", response_model=CarrierRead, status_code=status.HTTP_201_CREATED)
  199. def create_carrier(
  200. payload: CarrierCreate,
  201. db: Session = Depends(get_db),
  202. user: DbUserContext = Depends(get_db_current_user),
  203. ) -> CarrierRead:
  204. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  205. carrier = PlCarrier(
  206. id=generate_uuid(),
  207. tenant_no=tenant_no,
  208. carrier_name=payload.carrier_name,
  209. contact_name=payload.contact_name,
  210. contact_phone=payload.contact_phone,
  211. status=payload.status,
  212. business_license_urls=normalize_carrier_urls(payload.business_license_urls),
  213. road_transport_license_urls=normalize_carrier_urls(payload.road_transport_license_urls),
  214. transport_agreement_urls=normalize_carrier_urls(payload.transport_agreement_urls),
  215. transport_agreement_expire_date=payload.transport_agreement_expire_date,
  216. audit_status="pending",
  217. remark=payload.remark,
  218. create_by=user.id,
  219. create_dept_id=user.dept_id,
  220. )
  221. db.add(carrier)
  222. db.commit()
  223. db.refresh(carrier)
  224. return carrier_to_read(carrier)
  225. @router.get("/carrier/{id}", response_model=CarrierRead)
  226. def get_carrier(
  227. id: str,
  228. db: Session = Depends(get_db),
  229. user: DbUserContext = Depends(get_db_current_user),
  230. ) -> CarrierRead:
  231. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  232. carrier = _get_carrier(db, tenant_no, id, user)
  233. return carrier_to_read(carrier)
  234. @router.put("/carrier/{id}", response_model=CarrierRead)
  235. def update_carrier(
  236. id: str,
  237. payload: CarrierUpdate,
  238. db: Session = Depends(get_db),
  239. user: DbUserContext = Depends(get_db_current_user),
  240. ) -> CarrierRead:
  241. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  242. carrier = _get_carrier(db, tenant_no, id, user)
  243. payload_data = payload.model_dump(exclude_unset=True)
  244. for key in (
  245. "business_license_urls",
  246. "road_transport_license_urls",
  247. "transport_agreement_urls",
  248. ):
  249. if key in payload_data:
  250. payload_data[key] = normalize_carrier_urls(payload_data[key])
  251. for key, value in payload_data.items():
  252. setattr(carrier, key, value)
  253. carrier.update_by = user.id
  254. db.commit()
  255. db.refresh(carrier)
  256. return carrier_to_read(carrier)
  257. @router.delete("/carrier/{id}", status_code=status.HTTP_204_NO_CONTENT)
  258. def delete_carrier(
  259. id: str,
  260. db: Session = Depends(get_db),
  261. user: DbUserContext = Depends(get_db_current_user),
  262. ) -> None:
  263. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  264. carrier = _get_carrier(db, tenant_no, id, user)
  265. assert_row_deletable_by_creator(carrier, user_info_from_context(user))
  266. carrier.del_flag = 1
  267. carrier.update_by = user.id
  268. db.commit()
  269. @router.post("/carrier/{id}/approve", response_model=CarrierRead)
  270. def approve_carrier(
  271. id: str,
  272. db: Session = Depends(get_db),
  273. user: DbUserContext = Depends(get_db_current_user),
  274. ) -> CarrierRead:
  275. raise HTTPException(status_code=400, detail="承运商审核流程已停用")
  276. @router.post("/carrier/{id}/reject", response_model=CarrierRead)
  277. def reject_carrier(
  278. id: str,
  279. payload: CarrierAuditReject,
  280. db: Session = Depends(get_db),
  281. user: DbUserContext = Depends(get_db_current_user),
  282. ) -> CarrierRead:
  283. raise HTTPException(status_code=400, detail="承运商审核流程已停用")
  284. def _get_carrier(db: Session, tenant_no: str, carrier_id: str, user: DbUserContext) -> PlCarrier:
  285. carrier = (
  286. db.query(PlCarrier)
  287. .filter(
  288. PlCarrier.id == carrier_id,
  289. PlCarrier.tenant_no == tenant_no,
  290. PlCarrier.del_flag == 0,
  291. )
  292. .first()
  293. )
  294. if not carrier:
  295. raise HTTPException(status_code=404, detail="承运商不存在")
  296. return carrier
  297. # =============================================================================
  298. # 车辆 API
  299. # =============================================================================
  300. def _vehicle_to_read(
  301. vehicle: PlVehicle,
  302. summary: Optional[str] = None,
  303. default_driver_name: Optional[str] = None,
  304. *,
  305. cert_list: Optional[list[CertRead]] = None,
  306. ) -> VehicleRead:
  307. data = VehicleRead.model_validate(vehicle)
  308. if summary is not None:
  309. data.personnel_summary = summary
  310. if default_driver_name is not None:
  311. data.default_driver_name = default_driver_name
  312. if cert_list is not None:
  313. data.cert_list = cert_list
  314. return data
  315. def _vehicle_to_detail(
  316. db: Session, tenant_no: str, vehicle: PlVehicle
  317. ) -> VehicleDetailRead:
  318. summaries = load_vehicle_personnel_summaries(db, tenant_no, [vehicle.id])
  319. summary, default_name = summaries.get(vehicle.id, ("", None))
  320. personnel_list = load_vehicle_personnel_rows(db, tenant_no, vehicle.id)
  321. cert_list = certs_to_read(
  322. list_owner_certs(db, tenant_no, "vehicle", vehicle.id)
  323. )
  324. base = _vehicle_to_read(vehicle, summary, default_name)
  325. return VehicleDetailRead(
  326. **base.model_dump(by_alias=False, exclude={"cert_list"}),
  327. personnel_list=personnel_list,
  328. cert_list=cert_list,
  329. )
  330. @router.get("/vehicle", response_model=VehicleListResponse)
  331. def list_vehicles(
  332. q: Optional[str] = Query(default=None, description="模糊搜索车牌号"),
  333. vehicle_type: Optional[str] = Query(default=None, alias="vehicleType", description="车辆类型"),
  334. carrier_id: Optional[str] = Query(default=None, alias="carrierId", description="承运商ID"),
  335. vehicle_status: Optional[str] = Query(default=None, alias="vehicleStatus", description="车辆状态"),
  336. exclude_ineligible_carrier: Optional[bool] = Query(
  337. default=False,
  338. alias="excludeIneligibleCarrier",
  339. description="排除资质不全或协议过期的承运商车辆",
  340. ),
  341. page: int = Query(default=1, ge=1),
  342. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  343. db: Session = Depends(get_db),
  344. user: DbUserContext = Depends(get_db_current_user),
  345. ) -> VehicleListResponse:
  346. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  347. query = db.query(PlVehicle).filter(
  348. PlVehicle.tenant_no == tenant_no,
  349. PlVehicle.del_flag == 0,
  350. )
  351. if vehicle_type:
  352. query = query.filter(PlVehicle.vehicle_type == vehicle_type)
  353. if carrier_id:
  354. query = query.filter(PlVehicle.carrier_id == carrier_id)
  355. if vehicle_status:
  356. query = query.filter(PlVehicle.vehicle_status == vehicle_status)
  357. if q:
  358. query = query.filter(PlVehicle.license_plate.contains(q))
  359. # 车辆为主数据,列表不做 create_by 数据范围过滤
  360. query = query.order_by(
  361. case((PlVehicle.order_num.is_(None), 1), else_=0).asc(),
  362. PlVehicle.create_time.desc(),
  363. )
  364. if exclude_ineligible_carrier:
  365. vehicles = query.all()
  366. carrier_ids = {
  367. v.carrier_id for v in vehicles if v.vehicle_type == "carrier" and v.carrier_id
  368. }
  369. carrier_map: dict[str, PlCarrier] = {}
  370. if carrier_ids:
  371. carriers = (
  372. db.query(PlCarrier)
  373. .filter(
  374. PlCarrier.id.in_(carrier_ids),
  375. PlCarrier.tenant_no == tenant_no,
  376. PlCarrier.del_flag == 0,
  377. )
  378. .all()
  379. )
  380. carrier_map = {c.id: c for c in carriers}
  381. vehicles = [
  382. v
  383. for v in vehicles
  384. if v.vehicle_type != "carrier"
  385. or not v.carrier_id
  386. or (
  387. v.carrier_id in carrier_map
  388. and carrier_eligible_for_transport(carrier_map[v.carrier_id])
  389. )
  390. ]
  391. summaries = load_vehicle_personnel_summaries(
  392. db, tenant_no, [v.id for v in vehicles]
  393. )
  394. certs_by_owner = list_owner_certs_batch(
  395. db, tenant_no, "vehicle", [v.id for v in vehicles]
  396. )
  397. items = [
  398. _vehicle_to_read(
  399. v,
  400. *summaries.get(v.id, ("", None)),
  401. cert_list=certs_to_read(certs_by_owner.get(v.id, [])),
  402. )
  403. for v in vehicles
  404. ]
  405. return VehicleListResponse(total=len(items), list=items)
  406. total = query.count()
  407. offset = (page - 1) * page_size
  408. vehicles = query.offset(offset).limit(page_size).all()
  409. summaries = load_vehicle_personnel_summaries(
  410. db, tenant_no, [v.id for v in vehicles]
  411. )
  412. certs_by_owner = list_owner_certs_batch(
  413. db, tenant_no, "vehicle", [v.id for v in vehicles]
  414. )
  415. return VehicleListResponse(
  416. total=total,
  417. list=[
  418. _vehicle_to_read(
  419. v,
  420. *summaries.get(v.id, ("", None)),
  421. cert_list=certs_to_read(certs_by_owner.get(v.id, [])),
  422. )
  423. for v in vehicles
  424. ],
  425. )
  426. @router.post("/vehicle", response_model=VehicleDetailRead, status_code=status.HTTP_201_CREATED)
  427. def create_vehicle(
  428. payload: VehicleCreate,
  429. db: Session = Depends(get_db),
  430. user: DbUserContext = Depends(get_db_current_user),
  431. ) -> VehicleDetailRead:
  432. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  433. existing = (
  434. db.query(PlVehicle)
  435. .filter(
  436. PlVehicle.license_plate == payload.license_plate,
  437. PlVehicle.del_flag == 0,
  438. )
  439. .first()
  440. )
  441. if existing:
  442. raise HTTPException(status_code=400, detail="车牌号已存在")
  443. validate_vehicle_save_certs(
  444. db,
  445. tenant_no,
  446. vehicle_type=payload.vehicle_type,
  447. trailer_no=payload.trailer_no,
  448. cert_rows=payload.cert_list,
  449. personnel_rows=payload.personnel_list,
  450. )
  451. vehicle = PlVehicle(
  452. id=generate_uuid(),
  453. tenant_no=tenant_no,
  454. vehicle_type=payload.vehicle_type,
  455. carrier_id=payload.carrier_id,
  456. carrier_name=payload.carrier_name,
  457. license_plate=payload.license_plate,
  458. trailer_no=payload.trailer_no,
  459. load_capacity=payload.load_capacity,
  460. vehicle_status=payload.vehicle_status,
  461. photo_urls=payload.photo_urls,
  462. remark=payload.remark,
  463. create_by=user.id,
  464. create_dept_id=user.dept_id,
  465. )
  466. db.add(vehicle)
  467. db.flush()
  468. try:
  469. save_vehicle_personnel(
  470. db,
  471. vehicle.id,
  472. payload.personnel_list,
  473. tenant_no,
  474. user_id=user.id,
  475. dept_id=user.dept_id,
  476. vehicle_type=payload.vehicle_type,
  477. )
  478. save_owner_certs(
  479. db,
  480. "vehicle",
  481. vehicle.id,
  482. payload.cert_list,
  483. tenant_no,
  484. user_id=user.id,
  485. dept_id=user.dept_id,
  486. )
  487. db.commit()
  488. except HTTPException:
  489. db.rollback()
  490. raise
  491. except Exception:
  492. db.rollback()
  493. raise
  494. db.refresh(vehicle)
  495. return _vehicle_to_detail(db, tenant_no, vehicle)
  496. @router.get("/vehicle/{id}", response_model=VehicleDetailRead)
  497. def get_vehicle(
  498. id: str,
  499. db: Session = Depends(get_db),
  500. user: DbUserContext = Depends(get_db_current_user),
  501. ) -> VehicleDetailRead:
  502. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  503. vehicle = _get_vehicle(db, tenant_no, id, user)
  504. return _vehicle_to_detail(db, tenant_no, vehicle)
  505. @router.put("/vehicle/{id}", response_model=VehicleDetailRead)
  506. def update_vehicle(
  507. id: str,
  508. payload: VehicleUpdate,
  509. db: Session = Depends(get_db),
  510. user: DbUserContext = Depends(get_db_current_user),
  511. ) -> VehicleDetailRead:
  512. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  513. vehicle = _get_vehicle(db, tenant_no, id, user)
  514. personnel_list = payload.personnel_list
  515. cert_list = payload.cert_list
  516. data = payload.model_dump(
  517. exclude_unset=True, exclude={"personnel_list", "cert_list"}
  518. )
  519. effective_vehicle_type = data.get("vehicle_type", vehicle.vehicle_type)
  520. effective_trailer_no = data.get("trailer_no", vehicle.trailer_no)
  521. effective_cert_rows = (
  522. cert_list
  523. if cert_list is not None
  524. else list_owner_certs(db, tenant_no, "vehicle", vehicle.id)
  525. )
  526. effective_personnel_rows = personnel_list
  527. if effective_personnel_rows is None:
  528. effective_personnel_rows = load_vehicle_personnel_rows(db, tenant_no, vehicle.id)
  529. validate_vehicle_save_certs(
  530. db,
  531. tenant_no,
  532. vehicle_type=effective_vehicle_type,
  533. trailer_no=effective_trailer_no,
  534. cert_rows=effective_cert_rows,
  535. personnel_rows=effective_personnel_rows,
  536. )
  537. for key, value in data.items():
  538. setattr(vehicle, key, value)
  539. vehicle.update_by = user.id
  540. try:
  541. if personnel_list is not None:
  542. save_vehicle_personnel(
  543. db,
  544. vehicle.id,
  545. personnel_list,
  546. tenant_no,
  547. user_id=user.id,
  548. dept_id=user.dept_id,
  549. vehicle_type=effective_vehicle_type,
  550. )
  551. if cert_list is not None:
  552. save_owner_certs(
  553. db,
  554. "vehicle",
  555. vehicle.id,
  556. cert_list,
  557. tenant_no,
  558. user_id=user.id,
  559. dept_id=user.dept_id,
  560. )
  561. db.commit()
  562. except HTTPException:
  563. db.rollback()
  564. raise
  565. except Exception:
  566. db.rollback()
  567. raise
  568. db.refresh(vehicle)
  569. return _vehicle_to_detail(db, tenant_no, vehicle)
  570. @router.delete("/vehicle/{id}", status_code=status.HTTP_204_NO_CONTENT)
  571. def delete_vehicle(
  572. id: str,
  573. db: Session = Depends(get_db),
  574. user: DbUserContext = Depends(get_db_current_user),
  575. ) -> None:
  576. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  577. vehicle = _get_vehicle(db, tenant_no, id, user)
  578. assert_row_deletable_by_creator(vehicle, user_info_from_context(user))
  579. vehicle.del_flag = 1
  580. vehicle.update_by = user.id
  581. db.commit()
  582. def _get_vehicle(db: Session, tenant_no: str, vehicle_id: str, user: DbUserContext) -> PlVehicle:
  583. vehicle = (
  584. db.query(PlVehicle)
  585. .filter(
  586. PlVehicle.id == vehicle_id,
  587. PlVehicle.tenant_no == tenant_no,
  588. PlVehicle.del_flag == 0,
  589. )
  590. .first()
  591. )
  592. if not vehicle:
  593. raise HTTPException(status_code=404, detail="车辆不存在")
  594. return vehicle
  595. # =============================================================================
  596. # 人员 API
  597. # =============================================================================
  598. def _personnel_to_read(
  599. db: Session,
  600. tenant_no: str,
  601. personnel: PlPersonnel,
  602. *,
  603. cert_list: Optional[list[CertRead]] = None,
  604. ) -> PersonnelRead:
  605. nearest = nearest_expire_date(db, tenant_no, "personnel", personnel.id)
  606. data = PersonnelRead.model_validate(personnel)
  607. data.nearest_expire_date = nearest
  608. if cert_list is not None:
  609. data.cert_list = cert_list
  610. return data
  611. def _personnel_to_detail(
  612. db: Session, tenant_no: str, personnel: PlPersonnel
  613. ) -> PersonnelDetailRead:
  614. base = _personnel_to_read(db, tenant_no, personnel)
  615. cert_list = certs_to_read(
  616. list_owner_certs(db, tenant_no, "personnel", personnel.id)
  617. )
  618. return PersonnelDetailRead(
  619. **base.model_dump(by_alias=False, exclude={"cert_list"}),
  620. cert_list=cert_list,
  621. )
  622. @router.get("/personnel", response_model=PersonnelListResponse)
  623. def list_personnel(
  624. q: Optional[str] = Query(default=None, description="模糊搜索姓名/手机号"),
  625. status: Optional[str] = Query(default=None, description="状态"),
  626. personnel_affiliation: Optional[str] = Query(
  627. default=None, alias="personnelAffiliation", description="人员归属 self/carrier"
  628. ),
  629. personnel_type: Optional[str] = Query(
  630. default=None, alias="personnelType", description="人员类型 driver/escort"
  631. ),
  632. page: int = Query(default=1, ge=1),
  633. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  634. db: Session = Depends(get_db),
  635. user: DbUserContext = Depends(get_db_current_user),
  636. ) -> PersonnelListResponse:
  637. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  638. query = db.query(PlPersonnel).filter(
  639. PlPersonnel.tenant_no == tenant_no,
  640. PlPersonnel.del_flag == 0,
  641. )
  642. if status:
  643. query = query.filter(PlPersonnel.status == status)
  644. if personnel_affiliation:
  645. query = query.filter(PlPersonnel.personnel_affiliation == personnel_affiliation)
  646. if personnel_type:
  647. query = query.filter(PlPersonnel.personnel_type == personnel_type)
  648. if q:
  649. query = query.filter(
  650. or_(
  651. func.lower(PlPersonnel.personnel_name).contains(q.lower()),
  652. PlPersonnel.phone_no.contains(q),
  653. )
  654. )
  655. # 人员为主数据,列表不做 create_by 数据范围过滤
  656. query = query.order_by(
  657. case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),
  658. PlPersonnel.create_time.desc(),
  659. )
  660. total = query.count()
  661. offset = (page - 1) * page_size
  662. rows = query.offset(offset).limit(page_size).all()
  663. personnel_ids = [p.id for p in rows]
  664. certs_by_owner = list_owner_certs_batch(db, tenant_no, "personnel", personnel_ids)
  665. return PersonnelListResponse(
  666. total=total,
  667. list=[
  668. _personnel_to_read(
  669. db,
  670. tenant_no,
  671. p,
  672. cert_list=certs_to_read(certs_by_owner.get(p.id, [])),
  673. )
  674. for p in rows
  675. ],
  676. )
  677. @router.post("/personnel", response_model=PersonnelDetailRead, status_code=status.HTTP_201_CREATED)
  678. def create_personnel(
  679. payload: PersonnelCreate,
  680. db: Session = Depends(get_db),
  681. user: DbUserContext = Depends(get_db_current_user),
  682. ) -> PersonnelDetailRead:
  683. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  684. validate_personnel_required_certs(
  685. payload.cert_list,
  686. payload.personnel_type,
  687. personnel_affiliation=payload.personnel_affiliation,
  688. subject_label=f"「{payload.personnel_name}」",
  689. )
  690. personnel = PlPersonnel(
  691. id=generate_uuid(),
  692. tenant_no=tenant_no,
  693. personnel_name=payload.personnel_name,
  694. phone_no=payload.phone_no,
  695. age=payload.age,
  696. id_card_no=payload.id_card_no,
  697. personnel_type=payload.personnel_type,
  698. personnel_affiliation=payload.personnel_affiliation,
  699. status=payload.status,
  700. photo_urls=payload.photo_urls,
  701. remark=payload.remark,
  702. create_by=user.id,
  703. create_dept_id=user.dept_id,
  704. )
  705. db.add(personnel)
  706. db.flush()
  707. try:
  708. save_owner_certs(
  709. db,
  710. "personnel",
  711. personnel.id,
  712. payload.cert_list,
  713. tenant_no,
  714. user_id=user.id,
  715. dept_id=user.dept_id,
  716. )
  717. db.commit()
  718. except Exception:
  719. db.rollback()
  720. raise
  721. db.refresh(personnel)
  722. return _personnel_to_detail(db, tenant_no, personnel)
  723. @router.get("/personnel/{id}", response_model=PersonnelDetailRead)
  724. def get_personnel(
  725. id: str,
  726. db: Session = Depends(get_db),
  727. user: DbUserContext = Depends(get_db_current_user),
  728. ) -> PersonnelDetailRead:
  729. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  730. personnel = _get_personnel(db, tenant_no, id, user)
  731. return _personnel_to_detail(db, tenant_no, personnel)
  732. @router.put("/personnel/{id}", response_model=PersonnelDetailRead)
  733. def update_personnel(
  734. id: str,
  735. payload: PersonnelUpdate,
  736. db: Session = Depends(get_db),
  737. user: DbUserContext = Depends(get_db_current_user),
  738. ) -> PersonnelDetailRead:
  739. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  740. personnel = _get_personnel(db, tenant_no, id, user)
  741. cert_list = payload.cert_list
  742. data = payload.model_dump(exclude_unset=True, exclude={"cert_list"})
  743. effective_personnel_type = data.get("personnel_type", personnel.personnel_type)
  744. effective_personnel_affiliation = data.get(
  745. "personnel_affiliation", personnel.personnel_affiliation
  746. )
  747. effective_cert_rows = (
  748. cert_list
  749. if cert_list is not None
  750. else list_owner_certs(db, tenant_no, "personnel", personnel.id)
  751. )
  752. subject_name = data.get("personnel_name", personnel.personnel_name)
  753. validate_personnel_required_certs(
  754. effective_cert_rows,
  755. effective_personnel_type,
  756. personnel_affiliation=effective_personnel_affiliation,
  757. subject_label=f"「{subject_name}」",
  758. )
  759. for key, value in data.items():
  760. setattr(personnel, key, value)
  761. personnel.update_by = user.id
  762. try:
  763. if cert_list is not None:
  764. save_owner_certs(
  765. db,
  766. "personnel",
  767. personnel.id,
  768. cert_list,
  769. tenant_no,
  770. user_id=user.id,
  771. dept_id=user.dept_id,
  772. )
  773. db.commit()
  774. except Exception:
  775. db.rollback()
  776. raise
  777. db.refresh(personnel)
  778. return _personnel_to_detail(db, tenant_no, personnel)
  779. @router.delete("/personnel/{id}", status_code=status.HTTP_204_NO_CONTENT)
  780. def delete_personnel(
  781. id: str,
  782. db: Session = Depends(get_db),
  783. user: DbUserContext = Depends(get_db_current_user),
  784. ) -> None:
  785. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  786. personnel = _get_personnel(db, tenant_no, id, user)
  787. assert_row_deletable_by_creator(personnel, user_info_from_context(user))
  788. personnel.del_flag = 1
  789. personnel.update_by = user.id
  790. db.commit()
  791. def _get_personnel(db: Session, tenant_no: str, personnel_id: str, user: DbUserContext) -> PlPersonnel:
  792. personnel = (
  793. db.query(PlPersonnel)
  794. .filter(
  795. PlPersonnel.id == personnel_id,
  796. PlPersonnel.tenant_no == tenant_no,
  797. PlPersonnel.del_flag == 0,
  798. )
  799. .first()
  800. )
  801. if not personnel:
  802. raise HTTPException(status_code=404, detail="人员不存在")
  803. return personnel
  804. # =============================================================================
  805. # 司机 API(兼容别名,内部使用 pl_personnel)
  806. # =============================================================================
  807. def _personnel_to_driver_read(personnel: PlPersonnel) -> DriverRead:
  808. return DriverRead(
  809. id=personnel.id,
  810. driver_name=personnel.personnel_name,
  811. phone_no=personnel.phone_no,
  812. id_card_no=personnel.id_card_no,
  813. tenant_no=personnel.tenant_no,
  814. order_num=personnel.order_num,
  815. remark=personnel.remark,
  816. create_by=personnel.create_by,
  817. create_dept_id=personnel.create_dept_id,
  818. create_time=personnel.create_time,
  819. update_by=personnel.update_by,
  820. update_time=personnel.update_time,
  821. del_flag=personnel.del_flag,
  822. )
  823. @router.get("/driver", response_model=DriverListResponse)
  824. def list_drivers(
  825. q: Optional[str] = Query(default=None, description="模糊搜索姓名/手机号"),
  826. page: int = Query(default=1, ge=1),
  827. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  828. db: Session = Depends(get_db),
  829. user: DbUserContext = Depends(get_db_current_user),
  830. ) -> DriverListResponse:
  831. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  832. query = db.query(PlPersonnel).filter(
  833. PlPersonnel.tenant_no == tenant_no,
  834. PlPersonnel.del_flag == 0,
  835. )
  836. if q:
  837. query = query.filter(
  838. or_(
  839. func.lower(PlPersonnel.personnel_name).contains(q.lower()),
  840. PlPersonnel.phone_no.contains(q),
  841. )
  842. )
  843. # 司机列表为主数据选项,不做 create_by 数据范围过滤
  844. query = query.order_by(
  845. case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),
  846. PlPersonnel.create_time.desc(),
  847. )
  848. total = query.count()
  849. offset = (page - 1) * page_size
  850. rows = query.offset(offset).limit(page_size).all()
  851. return DriverListResponse(
  852. total=total,
  853. list=[_personnel_to_driver_read(p) for p in rows],
  854. )
  855. @router.post("/driver", response_model=DriverRead, status_code=status.HTTP_201_CREATED)
  856. def create_driver(
  857. payload: DriverCreate,
  858. db: Session = Depends(get_db),
  859. user: DbUserContext = Depends(get_db_current_user),
  860. ) -> DriverRead:
  861. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  862. personnel = PlPersonnel(
  863. id=generate_uuid(),
  864. tenant_no=tenant_no,
  865. personnel_name=payload.driver_name,
  866. phone_no=payload.phone_no,
  867. id_card_no=payload.id_card_no,
  868. status="1",
  869. remark=payload.remark,
  870. create_by=user.id,
  871. create_dept_id=user.dept_id,
  872. )
  873. db.add(personnel)
  874. db.commit()
  875. db.refresh(personnel)
  876. return _personnel_to_driver_read(personnel)
  877. @router.get("/driver/{id}", response_model=DriverRead)
  878. def get_driver(
  879. id: str,
  880. db: Session = Depends(get_db),
  881. user: DbUserContext = Depends(get_db_current_user),
  882. ) -> DriverRead:
  883. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  884. personnel = _get_personnel(db, tenant_no, id, user)
  885. return _personnel_to_driver_read(personnel)
  886. @router.put("/driver/{id}", response_model=DriverRead)
  887. def update_driver(
  888. id: str,
  889. payload: DriverUpdate,
  890. db: Session = Depends(get_db),
  891. user: DbUserContext = Depends(get_db_current_user),
  892. ) -> DriverRead:
  893. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  894. personnel = _get_personnel(db, tenant_no, id, user)
  895. if payload.driver_name is not None:
  896. personnel.personnel_name = payload.driver_name
  897. if payload.phone_no is not None:
  898. personnel.phone_no = payload.phone_no
  899. if payload.id_card_no is not None:
  900. personnel.id_card_no = payload.id_card_no
  901. if payload.remark is not None:
  902. personnel.remark = payload.remark
  903. personnel.update_by = user.id
  904. db.commit()
  905. db.refresh(personnel)
  906. return _personnel_to_driver_read(personnel)
  907. @router.delete("/driver/{id}", status_code=status.HTTP_204_NO_CONTENT)
  908. def delete_driver(
  909. id: str,
  910. db: Session = Depends(get_db),
  911. user: DbUserContext = Depends(get_db_current_user),
  912. ) -> None:
  913. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  914. personnel = _get_personnel(db, tenant_no, id, user)
  915. assert_row_deletable_by_creator(personnel, user_info_from_context(user))
  916. personnel.del_flag = 1
  917. personnel.update_by = user.id
  918. db.commit()
  919. # =============================================================================
  920. # 车辆-司机绑定 API(兼容,写入 pl_vehicle_personnel)
  921. # =============================================================================
  922. @router.post("/vehicle-driver", status_code=status.HTTP_201_CREATED)
  923. def bind_vehicle_driver(
  924. payload: VehicleDriverCreate,
  925. db: Session = Depends(get_db),
  926. user: DbUserContext = Depends(get_db_current_user),
  927. ) -> dict:
  928. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  929. _get_vehicle(db, tenant_no, payload.vehicle_id, user)
  930. _get_personnel(db, tenant_no, payload.driver_id, user)
  931. if payload.is_default == 1:
  932. db.query(PlVehiclePersonnel).filter(
  933. PlVehiclePersonnel.vehicle_id == payload.vehicle_id,
  934. PlVehiclePersonnel.role == "driver",
  935. PlVehiclePersonnel.del_flag == 0,
  936. ).update({"is_default": 0})
  937. vd = PlVehiclePersonnel(
  938. id=generate_uuid(),
  939. tenant_no=tenant_no,
  940. vehicle_id=payload.vehicle_id,
  941. personnel_id=payload.driver_id,
  942. role="driver",
  943. is_default=payload.is_default,
  944. create_by=user.id,
  945. create_dept_id=user.dept_id,
  946. )
  947. db.add(vd)
  948. db.commit()
  949. return {"id": vd.id}
  950. @router.delete("/vehicle-driver/{id}", status_code=status.HTTP_204_NO_CONTENT)
  951. def unbind_vehicle_driver(
  952. id: str,
  953. db: Session = Depends(get_db),
  954. user: DbUserContext = Depends(get_db_current_user),
  955. ) -> None:
  956. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  957. vd = (
  958. db.query(PlVehiclePersonnel)
  959. .filter(
  960. PlVehiclePersonnel.id == id,
  961. PlVehiclePersonnel.tenant_no == tenant_no,
  962. PlVehiclePersonnel.del_flag == 0,
  963. )
  964. .first()
  965. )
  966. if not vd:
  967. vd = (
  968. db.query(PlVehicleDriver)
  969. .filter(
  970. PlVehicleDriver.id == id,
  971. PlVehicleDriver.tenant_no == tenant_no,
  972. PlVehicleDriver.del_flag == 0,
  973. )
  974. .first()
  975. )
  976. if vd:
  977. assert_row_deletable_by_creator(vd, user_info_from_context(user))
  978. vd.del_flag = 1
  979. db.commit()
  980. @router.post("/paste-recognize/commit", response_model=PasteCommitResult)
  981. def paste_recognize_commit(
  982. payload: PasteCommitBody,
  983. db: Session = Depends(get_db),
  984. user: DbUserContext = Depends(get_db_current_user),
  985. ) -> PasteCommitResult:
  986. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  987. try:
  988. result = commit_paste_recognize(
  989. db,
  990. tenant_no,
  991. payload,
  992. user_id=user.id,
  993. dept_id=user.dept_id,
  994. )
  995. db.commit()
  996. return result
  997. except HTTPException:
  998. db.rollback()
  999. raise
  1000. except Exception:
  1001. db.rollback()
  1002. raise