pl_cert_required_service.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """运输计划 - 必备证书校验"""
  2. from __future__ import annotations
  3. from typing import Iterable, Optional, Sequence
  4. from fastapi import HTTPException
  5. from sqlalchemy.orm import Session
  6. from app.models.pl_models import PlPersonnel
  7. from app.schemas.pl.base_data import CertRow, VehiclePersonnelRow
  8. from app.services.pl_cert_service import list_owner_certs
  9. VEHICLE_TYPE_SELF = "self"
  10. VEHICLE_TYPE_CARRIER = "carrier"
  11. SELF_HEAD_REQUIRED = ("行驶证", "营运证", "保险单")
  12. SELF_TRAILER_REQUIRED = ("行驶证", "营运证", "保险单", "罐检报告")
  13. CARRIER_HEAD_REQUIRED = ("行驶证", "营运证")
  14. CARRIER_TRAILER_REQUIRED = ("行驶证", "营运证")
  15. PERSONNEL_DRIVER_REQUIRED = ("身份证", "驾驶证", "驾驶员从业资格证")
  16. PERSONNEL_ESCORT_REQUIRED = ("身份证", "押运员从业资格证")
  17. PERSONNEL_BOTH_REQUIRED = (
  18. "身份证",
  19. "驾驶证",
  20. "驾驶员从业资格证",
  21. "押运员从业资格证",
  22. )
  23. VEHICLE_PART_HEAD = "head"
  24. VEHICLE_PART_TRAILER = "trailer"
  25. HUOLALA_TRAILER_NO = "货拉拉"
  26. def is_huolala_trailer_no(trailer_no: str | None) -> bool:
  27. return (trailer_no or "").strip() == HUOLALA_TRAILER_NO
  28. def should_show_trailer_cert_slots(trailer_no: str | None) -> bool:
  29. text = (trailer_no or "").strip()
  30. return bool(text) and not is_huolala_trailer_no(text)
  31. def should_require_trailer_certs(vehicle_type: str | None, trailer_no: str | None) -> bool:
  32. return (vehicle_type or "").strip() == VEHICLE_TYPE_SELF and should_show_trailer_cert_slots(
  33. trailer_no
  34. )
  35. def match_required_cert_name(actual_name: str | None, required_name: str) -> bool:
  36. name = (actual_name or "").strip()
  37. if not name:
  38. return False
  39. if required_name == "押运员从业资格证":
  40. return name == "押运员从业资格证" or name == "押运员证"
  41. return name == required_name
  42. def _cert_row_complete(cert: CertRow | dict, required_name: str) -> bool:
  43. if isinstance(cert, dict):
  44. cert_name = cert.get("cert_name") or cert.get("certName")
  45. expire_date = cert.get("expire_date") or cert.get("expireDate")
  46. else:
  47. cert_name = cert.cert_name
  48. expire_date = cert.expire_date
  49. if not match_required_cert_name(cert_name, required_name):
  50. return False
  51. if not expire_date:
  52. return False
  53. # 证书附件非必填(四页车辆/驾押人员统一)
  54. return True
  55. def _find_cert_for_required(
  56. cert_rows: Sequence[CertRow | dict],
  57. required_name: str,
  58. *,
  59. vehicle_part: str | None = None,
  60. ) -> CertRow | dict | None:
  61. for cert in cert_rows:
  62. if isinstance(cert, dict):
  63. name = cert.get("cert_name") or cert.get("certName")
  64. part = cert.get("vehicle_part") or cert.get("vehiclePart")
  65. else:
  66. name = cert.cert_name
  67. part = cert.vehicle_part
  68. if vehicle_part is not None and (part or VEHICLE_PART_HEAD) != vehicle_part:
  69. continue
  70. if match_required_cert_name(name, required_name):
  71. return cert
  72. return None
  73. def _missing_field_labels(cert: CertRow | dict | None, required_name: str) -> list[str]:
  74. if cert is None:
  75. return ["有效期"]
  76. missing: list[str] = []
  77. if isinstance(cert, dict):
  78. expire_date = cert.get("expire_date") or cert.get("expireDate")
  79. cert_name = cert.get("cert_name") or cert.get("certName")
  80. else:
  81. expire_date = cert.expire_date
  82. cert_name = cert.cert_name
  83. if not match_required_cert_name(cert_name, required_name):
  84. return ["有效期"]
  85. if not expire_date:
  86. missing.append("有效期")
  87. # 证书附件非必填
  88. return missing
  89. def collect_required_cert_errors(
  90. cert_rows: Sequence[CertRow | dict],
  91. required_names: Iterable[str],
  92. *,
  93. part_label: str = "",
  94. ) -> list[str]:
  95. errors: list[str] = []
  96. prefix = f"{part_label}" if part_label else ""
  97. vehicle_part = None
  98. if part_label in ("主车", "head"):
  99. vehicle_part = VEHICLE_PART_HEAD
  100. elif part_label in ("挂车", "trailer"):
  101. vehicle_part = VEHICLE_PART_TRAILER
  102. for required_name in required_names:
  103. cert = _find_cert_for_required(
  104. cert_rows, required_name, vehicle_part=vehicle_part
  105. )
  106. missing = _missing_field_labels(cert, required_name)
  107. if missing:
  108. label = f"{prefix}「{required_name}」" if prefix else f"「{required_name}」"
  109. errors.append(f"{label}缺少:{'、'.join(missing)}")
  110. return errors
  111. def get_required_personnel_cert_names(personnel_type: str | None) -> tuple[str, ...]:
  112. ptype = (personnel_type or "").strip()
  113. if ptype == "escort":
  114. return PERSONNEL_ESCORT_REQUIRED
  115. if ptype == "driver":
  116. return PERSONNEL_DRIVER_REQUIRED
  117. if ptype == "both":
  118. return PERSONNEL_BOTH_REQUIRED
  119. return PERSONNEL_BOTH_REQUIRED
  120. def validate_personnel_type_role_compat(personnel_type: str | None, role: str | None) -> None:
  121. ptype = (personnel_type or "").strip()
  122. r = (role or "").strip()
  123. if not ptype or not r:
  124. return
  125. if ptype == "driver" and r == "escort":
  126. raise HTTPException(status_code=400, detail="驾驶员类型人员不可关联为押运员")
  127. if ptype == "escort" and r == "driver":
  128. raise HTTPException(status_code=400, detail="押运员类型人员不可关联为驾驶员")
  129. def validate_personnel_required_certs(
  130. cert_rows: Sequence[CertRow | dict],
  131. personnel_type: str | None,
  132. *,
  133. personnel_affiliation: str | None = None,
  134. subject_label: str = "",
  135. ) -> None:
  136. affiliation = (personnel_affiliation or VEHICLE_TYPE_SELF).strip()
  137. if affiliation == VEHICLE_TYPE_CARRIER:
  138. return
  139. ptype = (personnel_type or "").strip()
  140. if not ptype:
  141. raise HTTPException(status_code=400, detail="人员类型不能为空")
  142. required = get_required_personnel_cert_names(ptype)
  143. errors = collect_required_cert_errors(cert_rows, required)
  144. if errors:
  145. prefix = f"人员{subject_label}" if subject_label else "人员"
  146. raise HTTPException(
  147. status_code=400,
  148. detail=f"必备证书未齐全:{prefix}{';'.join(errors)}",
  149. )
  150. def validate_self_vehicle_required_certs(
  151. cert_rows: Sequence[CertRow | dict],
  152. *,
  153. trailer_no: str | None = None,
  154. ) -> None:
  155. errors: list[str] = []
  156. errors.extend(
  157. collect_required_cert_errors(
  158. cert_rows, SELF_HEAD_REQUIRED, part_label="主车"
  159. )
  160. )
  161. if should_require_trailer_certs(VEHICLE_TYPE_SELF, trailer_no):
  162. errors.extend(
  163. collect_required_cert_errors(
  164. cert_rows, SELF_TRAILER_REQUIRED, part_label="挂车"
  165. )
  166. )
  167. if errors:
  168. raise HTTPException(
  169. status_code=400,
  170. detail=f"必备证书未齐全:{';'.join(errors)}",
  171. )
  172. def validate_carrier_vehicle_required_certs(
  173. cert_rows: Sequence[CertRow | dict],
  174. *,
  175. trailer_no: str | None = None,
  176. ) -> None:
  177. """三方车辆证件不强制;保留函数供兼容调用,恒为通过。"""
  178. _ = (cert_rows, trailer_no)
  179. return
  180. def validate_vehicle_personnel_affiliation(
  181. db: Session,
  182. tenant_no: str,
  183. vehicle_type: str | None,
  184. personnel_rows: Sequence[VehiclePersonnelRow],
  185. ) -> None:
  186. vtype = (vehicle_type or "").strip()
  187. if vtype not in (VEHICLE_TYPE_SELF, VEHICLE_TYPE_CARRIER):
  188. return
  189. label = "山峡" if vtype == VEHICLE_TYPE_SELF else "非山峡"
  190. for row in personnel_rows:
  191. personnel_id = (row.personnel_id or "").strip()
  192. if not personnel_id:
  193. continue
  194. personnel = (
  195. db.query(PlPersonnel)
  196. .filter(
  197. PlPersonnel.id == personnel_id,
  198. PlPersonnel.tenant_no == tenant_no,
  199. PlPersonnel.del_flag == 0,
  200. )
  201. .first()
  202. )
  203. if not personnel:
  204. continue
  205. affiliation = (personnel.personnel_affiliation or VEHICLE_TYPE_SELF).strip()
  206. if affiliation != vtype:
  207. name = (row.personnel_name or personnel.personnel_name or personnel_id).strip()
  208. raise HTTPException(
  209. status_code=400,
  210. detail=f"关联人员「{name}」归属与车辆类型不匹配,{label}车辆仅可关联{label}人员",
  211. )
  212. def validate_vehicle_personnel_cert_links(
  213. db: Session,
  214. tenant_no: str,
  215. personnel_rows: Sequence[VehiclePersonnelRow],
  216. ) -> None:
  217. for row in personnel_rows:
  218. personnel_id = (row.personnel_id or "").strip()
  219. if not personnel_id:
  220. continue
  221. personnel = (
  222. db.query(PlPersonnel)
  223. .filter(
  224. PlPersonnel.id == personnel_id,
  225. PlPersonnel.tenant_no == tenant_no,
  226. PlPersonnel.del_flag == 0,
  227. )
  228. .first()
  229. )
  230. if not personnel:
  231. continue
  232. validate_personnel_type_role_compat(personnel.personnel_type, row.role)
  233. cert_rows = list_owner_certs(db, tenant_no, "personnel", personnel_id)
  234. role = (row.role or "").strip()
  235. required = (
  236. PERSONNEL_DRIVER_REQUIRED
  237. if role == "driver"
  238. else PERSONNEL_ESCORT_REQUIRED
  239. )
  240. errors = collect_required_cert_errors(cert_rows, required)
  241. if errors:
  242. name = (row.personnel_name or personnel.personnel_name or personnel_id).strip()
  243. raise HTTPException(
  244. status_code=400,
  245. detail=(
  246. f"关联人员「{name}」必备证书未齐全:{';'.join(errors)}"
  247. ),
  248. )
  249. def validate_vehicle_save_certs(
  250. db: Session,
  251. tenant_no: str,
  252. *,
  253. vehicle_type: str | None,
  254. trailer_no: str | None,
  255. cert_rows: Sequence[CertRow | dict],
  256. personnel_rows: Sequence[VehiclePersonnelRow] | None = None,
  257. ) -> None:
  258. vtype = (vehicle_type or "").strip()
  259. if vtype == VEHICLE_TYPE_SELF:
  260. validate_self_vehicle_required_certs(cert_rows, trailer_no=trailer_no)
  261. if vtype in (VEHICLE_TYPE_SELF, VEHICLE_TYPE_CARRIER) and personnel_rows:
  262. validate_vehicle_personnel_affiliation(
  263. db, tenant_no, vtype, personnel_rows
  264. )
  265. if vtype == VEHICLE_TYPE_SELF:
  266. validate_vehicle_personnel_cert_links(db, tenant_no, personnel_rows)