pl_cert_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. """运输计划 - 证书 CRUD 与到期扫描"""
  2. from __future__ import annotations
  3. from datetime import date
  4. from typing import Optional
  5. from fastapi import HTTPException
  6. from sqlalchemy.orm import Session
  7. from app.core.utils import generate_uuid
  8. from app.models.pl_models import PlCert, PlDocumentReminder, PlPersonnel, PlVehicle
  9. from app.schemas.pl.base_data import CertRead, CertRow
  10. def list_owner_certs(
  11. db: Session, tenant_no: str, owner_type: str, owner_id: str
  12. ) -> list[PlCert]:
  13. return (
  14. db.query(PlCert)
  15. .filter(
  16. PlCert.tenant_no == tenant_no,
  17. PlCert.owner_type == owner_type,
  18. PlCert.owner_id == owner_id,
  19. PlCert.del_flag == 0,
  20. )
  21. .order_by(PlCert.order_num.asc(), PlCert.create_time.asc())
  22. .all()
  23. )
  24. def list_owner_certs_batch(
  25. db: Session, tenant_no: str, owner_type: str, owner_ids: list[str]
  26. ) -> dict[str, list[PlCert]]:
  27. if not owner_ids:
  28. return {}
  29. rows = (
  30. db.query(PlCert)
  31. .filter(
  32. PlCert.tenant_no == tenant_no,
  33. PlCert.owner_type == owner_type,
  34. PlCert.owner_id.in_(owner_ids),
  35. PlCert.del_flag == 0,
  36. )
  37. .order_by(PlCert.order_num.asc(), PlCert.create_time.asc())
  38. .all()
  39. )
  40. grouped: dict[str, list[PlCert]] = {}
  41. for cert in rows:
  42. grouped.setdefault(cert.owner_id, []).append(cert)
  43. return grouped
  44. def certs_to_read(certs: list[PlCert]) -> list[CertRead]:
  45. return [
  46. CertRead(
  47. id=c.id,
  48. cert_id=c.id,
  49. vehicle_part=c.vehicle_part,
  50. cert_name=c.cert_name,
  51. cert_no=c.cert_no,
  52. expire_date=c.expire_date,
  53. remind_days=c.remind_days,
  54. attachment_urls=c.attachment_urls,
  55. owner_type=c.owner_type,
  56. owner_id=c.owner_id,
  57. status=c.status,
  58. tenant_no=c.tenant_no,
  59. create_time=c.create_time,
  60. update_time=c.update_time,
  61. )
  62. for c in certs
  63. ]
  64. def _normalize_cert_row(row: CertRow | dict) -> CertRow:
  65. if isinstance(row, CertRow):
  66. return row
  67. return CertRow.model_validate(row)
  68. def save_owner_certs(
  69. db: Session,
  70. owner_type: str,
  71. owner_id: str,
  72. cert_rows: list[CertRow | dict],
  73. tenant_no: str,
  74. *,
  75. user_id: str,
  76. dept_id: str,
  77. ) -> None:
  78. """按 ID 差量保存;旧请求仅复用唯一业务键,遗漏旧证软删。"""
  79. existing = db.query(PlCert).filter(
  80. PlCert.tenant_no == tenant_no,
  81. PlCert.owner_type == owner_type,
  82. PlCert.owner_id == owner_id,
  83. PlCert.del_flag == 0,
  84. ).populate_existing().with_for_update().all()
  85. existing_map = {c.id: c for c in existing}
  86. rows = [_normalize_cert_row(row) for row in cert_rows]
  87. explicit_ids: set[str] = set()
  88. for row in rows:
  89. cert_id = (row.cert_id or "").strip()
  90. if cert_id:
  91. if cert_id in explicit_ids or cert_id not in existing_map:
  92. raise HTTPException(400, "证书ID重复或不属于当前主体")
  93. explicit_ids.add(cert_id)
  94. def business_key(row):
  95. return tuple((getattr(row, key) or "").strip() for key in (
  96. "vehicle_part", "cert_name", "cert_no"
  97. ))
  98. by_key = {}
  99. for cert in existing:
  100. if cert.id not in explicit_ids:
  101. by_key.setdefault(business_key(cert), []).append(cert)
  102. prepared = []
  103. legacy_keys = set()
  104. for row in rows:
  105. cert_id = (row.cert_id or "").strip()
  106. if cert_id:
  107. cert = existing_map[cert_id]
  108. else:
  109. key = business_key(row)
  110. matches = by_key.get(key, [])
  111. if key in legacy_keys or len(matches) > 1:
  112. raise HTTPException(400, "证书业务键重复或不唯一,请刷新后使用证书ID保存")
  113. legacy_keys.add(key)
  114. cert = matches[0] if matches else None
  115. prepared.append((row, cert))
  116. keep_ids: set[str] = set()
  117. for idx, (row, cert) in enumerate(prepared):
  118. if cert is not None:
  119. for field in (
  120. "vehicle_part", "cert_name", "cert_no", "expire_date",
  121. "remind_days", "attachment_urls",
  122. ):
  123. # A submitted certificate is a full form row; UI-cleared optional
  124. # fields are omitted by JSON and use the schema's empty defaults.
  125. setattr(cert, field, getattr(row, field))
  126. cert.order_num = idx
  127. cert.update_by = user_id
  128. keep_ids.add(cert.id)
  129. else:
  130. cert = PlCert(
  131. id=generate_uuid(),
  132. tenant_no=tenant_no,
  133. owner_type=owner_type,
  134. owner_id=owner_id,
  135. vehicle_part=row.vehicle_part,
  136. cert_name=row.cert_name,
  137. cert_no=row.cert_no,
  138. expire_date=row.expire_date,
  139. remind_days=row.remind_days,
  140. attachment_urls=row.attachment_urls,
  141. status="valid",
  142. order_num=idx,
  143. create_by=user_id,
  144. create_dept_id=dept_id,
  145. )
  146. db.add(cert)
  147. db.flush()
  148. keep_ids.add(cert.id)
  149. for cert in existing:
  150. if cert.id not in keep_ids:
  151. cert.del_flag = 1
  152. cert.update_by = user_id
  153. scan_cert_expiry(db, tenant_no)
  154. def nearest_expire_date(
  155. db: Session, tenant_no: str, owner_type: str, owner_id: str
  156. ) -> Optional[date]:
  157. row = (
  158. db.query(PlCert.expire_date)
  159. .filter(
  160. PlCert.tenant_no == tenant_no,
  161. PlCert.owner_type == owner_type,
  162. PlCert.owner_id == owner_id,
  163. PlCert.del_flag == 0,
  164. PlCert.expire_date.isnot(None),
  165. )
  166. .order_by(PlCert.expire_date.asc())
  167. .first()
  168. )
  169. return row[0] if row else None
  170. def _resolve_owner_label(
  171. db: Session, tenant_no: str, owner_type: str, owner_id: str
  172. ) -> str:
  173. if owner_type == "vehicle":
  174. vehicle = (
  175. db.query(PlVehicle)
  176. .filter(
  177. PlVehicle.id == owner_id,
  178. PlVehicle.tenant_no == tenant_no,
  179. PlVehicle.del_flag == 0,
  180. )
  181. .first()
  182. )
  183. return vehicle.license_plate if vehicle else owner_id
  184. personnel = (
  185. db.query(PlPersonnel)
  186. .filter(
  187. PlPersonnel.id == owner_id,
  188. PlPersonnel.tenant_no == tenant_no,
  189. PlPersonnel.del_flag == 0,
  190. )
  191. .first()
  192. )
  193. return personnel.personnel_name if personnel else owner_id
  194. def _pending_reminder_for_cert(
  195. db: Session, cert_id: str
  196. ) -> Optional[PlDocumentReminder]:
  197. return (
  198. db.query(PlDocumentReminder)
  199. .filter(
  200. PlDocumentReminder.cert_id == cert_id,
  201. PlDocumentReminder.status == "pending",
  202. PlDocumentReminder.del_flag == 0,
  203. )
  204. .first()
  205. )
  206. def _resolve_pending_reminder(
  207. reminder: PlDocumentReminder,
  208. *,
  209. remark: str,
  210. expire_date: Optional[date] = None,
  211. days_remaining: Optional[int] = None,
  212. document_type: Optional[str] = None,
  213. document_no: Optional[str] = None,
  214. ) -> None:
  215. """关闭待处理提醒:证件已处理/退出窗口后不再继续提醒。"""
  216. reminder.status = "resolved"
  217. reminder.remark = remark
  218. if expire_date is not None:
  219. reminder.expire_date = expire_date
  220. if days_remaining is not None:
  221. reminder.days_remaining = days_remaining
  222. if document_type is not None:
  223. reminder.document_type = document_type
  224. if document_no is not None:
  225. reminder.document_no = document_no
  226. def scan_cert_expiry(db: Session, tenant_no: Optional[str] = None, *, today: Optional[date] = None) -> int:
  227. """扫描 pl_cert 生成到期提醒;退出提醒窗口或证件失效时关闭旧 pending。"""
  228. today = today or date.today()
  229. query = db.query(PlCert).filter(
  230. PlCert.del_flag == 0,
  231. PlCert.expire_date.isnot(None),
  232. PlCert.status == "valid",
  233. )
  234. if tenant_no:
  235. query = query.filter(PlCert.tenant_no == tenant_no)
  236. active_cert_ids: set[str] = set()
  237. created_count = 0
  238. for cert in query.all():
  239. active_cert_ids.add(cert.id)
  240. days_remaining = (cert.expire_date - today).days
  241. existing = _pending_reminder_for_cert(db, cert.id)
  242. # 尚未进入提醒窗口:关闭因旧有效期留下的 pending,等真正临近再提醒
  243. if days_remaining > cert.remind_days:
  244. if existing:
  245. _resolve_pending_reminder(
  246. existing,
  247. remark="系统自动关闭:证件有效期已更新,已退出提醒窗口",
  248. expire_date=cert.expire_date,
  249. days_remaining=days_remaining,
  250. document_type=cert.cert_name,
  251. document_no=cert.cert_no,
  252. )
  253. continue
  254. owner_label = _resolve_owner_label(
  255. db, cert.tenant_no, cert.owner_type, cert.owner_id
  256. )
  257. driver_id = cert.owner_id if cert.owner_type == "personnel" else None
  258. driver_name = owner_label if cert.owner_type == "personnel" else None
  259. if existing:
  260. existing.owner_type = cert.owner_type
  261. existing.owner_id = cert.owner_id
  262. existing.owner_label = owner_label
  263. existing.driver_id = driver_id
  264. existing.driver_name = driver_name
  265. existing.document_type = cert.cert_name
  266. existing.document_no = cert.cert_no
  267. existing.expire_date = cert.expire_date
  268. existing.days_remaining = days_remaining
  269. continue
  270. reminder = PlDocumentReminder(
  271. id=generate_uuid(),
  272. tenant_no=cert.tenant_no,
  273. cert_id=cert.id,
  274. owner_type=cert.owner_type,
  275. owner_id=cert.owner_id,
  276. owner_label=owner_label,
  277. driver_id=driver_id,
  278. driver_name=driver_name,
  279. document_type=cert.cert_name,
  280. document_no=cert.cert_no,
  281. expire_date=cert.expire_date,
  282. days_remaining=days_remaining,
  283. status="pending",
  284. create_by=cert.create_by or "system",
  285. create_dept_id=cert.create_dept_id or "",
  286. )
  287. db.add(reminder)
  288. created_count += 1
  289. # 软删/作废/无有效期的证件:关闭仍挂着的 pending
  290. stale_q = db.query(PlDocumentReminder).filter(
  291. PlDocumentReminder.status == "pending",
  292. PlDocumentReminder.del_flag == 0,
  293. PlDocumentReminder.cert_id.isnot(None),
  294. )
  295. if tenant_no:
  296. stale_q = stale_q.filter(PlDocumentReminder.tenant_no == tenant_no)
  297. for reminder in stale_q.all():
  298. if reminder.cert_id in active_cert_ids:
  299. continue
  300. _resolve_pending_reminder(
  301. reminder,
  302. remark="系统自动关闭:关联证件已删除或失效",
  303. )
  304. return created_count