Files
shanxia2.0/backend/app/services/pl_cert_service.py
T

341 lines
11 KiB
Python

"""运输计划 - 证书 CRUD 与到期扫描"""
from __future__ import annotations
from datetime import date
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.pl_models import PlCert, PlDocumentReminder, PlPersonnel, PlVehicle
from app.schemas.pl.base_data import CertRead, CertRow
def list_owner_certs(
db: Session, tenant_no: str, owner_type: str, owner_id: str
) -> list[PlCert]:
return (
db.query(PlCert)
.filter(
PlCert.tenant_no == tenant_no,
PlCert.owner_type == owner_type,
PlCert.owner_id == owner_id,
PlCert.del_flag == 0,
)
.order_by(PlCert.order_num.asc(), PlCert.create_time.asc())
.all()
)
def list_owner_certs_batch(
db: Session, tenant_no: str, owner_type: str, owner_ids: list[str]
) -> dict[str, list[PlCert]]:
if not owner_ids:
return {}
rows = (
db.query(PlCert)
.filter(
PlCert.tenant_no == tenant_no,
PlCert.owner_type == owner_type,
PlCert.owner_id.in_(owner_ids),
PlCert.del_flag == 0,
)
.order_by(PlCert.order_num.asc(), PlCert.create_time.asc())
.all()
)
grouped: dict[str, list[PlCert]] = {}
for cert in rows:
grouped.setdefault(cert.owner_id, []).append(cert)
return grouped
def certs_to_read(certs: list[PlCert]) -> list[CertRead]:
return [
CertRead(
id=c.id,
cert_id=c.id,
vehicle_part=c.vehicle_part,
cert_name=c.cert_name,
cert_no=c.cert_no,
expire_date=c.expire_date,
remind_days=c.remind_days,
attachment_urls=c.attachment_urls,
owner_type=c.owner_type,
owner_id=c.owner_id,
status=c.status,
tenant_no=c.tenant_no,
create_time=c.create_time,
update_time=c.update_time,
)
for c in certs
]
def _normalize_cert_row(row: CertRow | dict) -> CertRow:
if isinstance(row, CertRow):
return row
return CertRow.model_validate(row)
def save_owner_certs(
db: Session,
owner_type: str,
owner_id: str,
cert_rows: list[CertRow | dict],
tenant_no: str,
*,
user_id: str,
dept_id: str,
) -> None:
"""按 ID 差量保存;旧请求仅复用唯一业务键,遗漏旧证软删。"""
existing = db.query(PlCert).filter(
PlCert.tenant_no == tenant_no,
PlCert.owner_type == owner_type,
PlCert.owner_id == owner_id,
PlCert.del_flag == 0,
).populate_existing().with_for_update().all()
existing_map = {c.id: c for c in existing}
rows = [_normalize_cert_row(row) for row in cert_rows]
explicit_ids: set[str] = set()
for row in rows:
cert_id = (row.cert_id or "").strip()
if cert_id:
if cert_id in explicit_ids or cert_id not in existing_map:
raise HTTPException(400, "证书ID重复或不属于当前主体")
explicit_ids.add(cert_id)
def business_key(row):
return tuple((getattr(row, key) or "").strip() for key in (
"vehicle_part", "cert_name", "cert_no"
))
by_key = {}
for cert in existing:
if cert.id not in explicit_ids:
by_key.setdefault(business_key(cert), []).append(cert)
prepared = []
legacy_keys = set()
for row in rows:
cert_id = (row.cert_id or "").strip()
if cert_id:
cert = existing_map[cert_id]
else:
key = business_key(row)
matches = by_key.get(key, [])
if key in legacy_keys or len(matches) > 1:
raise HTTPException(400, "证书业务键重复或不唯一,请刷新后使用证书ID保存")
legacy_keys.add(key)
cert = matches[0] if matches else None
prepared.append((row, cert))
keep_ids: set[str] = set()
for idx, (row, cert) in enumerate(prepared):
if cert is not None:
for field in (
"vehicle_part", "cert_name", "cert_no", "expire_date",
"remind_days", "attachment_urls",
):
# A submitted certificate is a full form row; UI-cleared optional
# fields are omitted by JSON and use the schema's empty defaults.
setattr(cert, field, getattr(row, field))
cert.order_num = idx
cert.update_by = user_id
keep_ids.add(cert.id)
else:
cert = PlCert(
id=generate_uuid(),
tenant_no=tenant_no,
owner_type=owner_type,
owner_id=owner_id,
vehicle_part=row.vehicle_part,
cert_name=row.cert_name,
cert_no=row.cert_no,
expire_date=row.expire_date,
remind_days=row.remind_days,
attachment_urls=row.attachment_urls,
status="valid",
order_num=idx,
create_by=user_id,
create_dept_id=dept_id,
)
db.add(cert)
db.flush()
keep_ids.add(cert.id)
for cert in existing:
if cert.id not in keep_ids:
cert.del_flag = 1
cert.update_by = user_id
scan_cert_expiry(db, tenant_no)
def nearest_expire_date(
db: Session, tenant_no: str, owner_type: str, owner_id: str
) -> Optional[date]:
row = (
db.query(PlCert.expire_date)
.filter(
PlCert.tenant_no == tenant_no,
PlCert.owner_type == owner_type,
PlCert.owner_id == owner_id,
PlCert.del_flag == 0,
PlCert.expire_date.isnot(None),
)
.order_by(PlCert.expire_date.asc())
.first()
)
return row[0] if row else None
def _resolve_owner_label(
db: Session, tenant_no: str, owner_type: str, owner_id: str
) -> str:
if owner_type == "vehicle":
vehicle = (
db.query(PlVehicle)
.filter(
PlVehicle.id == owner_id,
PlVehicle.tenant_no == tenant_no,
PlVehicle.del_flag == 0,
)
.first()
)
return vehicle.license_plate if vehicle else owner_id
personnel = (
db.query(PlPersonnel)
.filter(
PlPersonnel.id == owner_id,
PlPersonnel.tenant_no == tenant_no,
PlPersonnel.del_flag == 0,
)
.first()
)
return personnel.personnel_name if personnel else owner_id
def _pending_reminder_for_cert(
db: Session, cert_id: str
) -> Optional[PlDocumentReminder]:
return (
db.query(PlDocumentReminder)
.filter(
PlDocumentReminder.cert_id == cert_id,
PlDocumentReminder.status == "pending",
PlDocumentReminder.del_flag == 0,
)
.first()
)
def _resolve_pending_reminder(
reminder: PlDocumentReminder,
*,
remark: str,
expire_date: Optional[date] = None,
days_remaining: Optional[int] = None,
document_type: Optional[str] = None,
document_no: Optional[str] = None,
) -> None:
"""关闭待处理提醒:证件已处理/退出窗口后不再继续提醒。"""
reminder.status = "resolved"
reminder.remark = remark
if expire_date is not None:
reminder.expire_date = expire_date
if days_remaining is not None:
reminder.days_remaining = days_remaining
if document_type is not None:
reminder.document_type = document_type
if document_no is not None:
reminder.document_no = document_no
def scan_cert_expiry(db: Session, tenant_no: Optional[str] = None, *, today: Optional[date] = None) -> int:
"""扫描 pl_cert 生成到期提醒;退出提醒窗口或证件失效时关闭旧 pending。"""
today = today or date.today()
query = db.query(PlCert).filter(
PlCert.del_flag == 0,
PlCert.expire_date.isnot(None),
PlCert.status == "valid",
)
if tenant_no:
query = query.filter(PlCert.tenant_no == tenant_no)
active_cert_ids: set[str] = set()
created_count = 0
for cert in query.all():
active_cert_ids.add(cert.id)
days_remaining = (cert.expire_date - today).days
existing = _pending_reminder_for_cert(db, cert.id)
# 尚未进入提醒窗口:关闭因旧有效期留下的 pending,等真正临近再提醒
if days_remaining > cert.remind_days:
if existing:
_resolve_pending_reminder(
existing,
remark="系统自动关闭:证件有效期已更新,已退出提醒窗口",
expire_date=cert.expire_date,
days_remaining=days_remaining,
document_type=cert.cert_name,
document_no=cert.cert_no,
)
continue
owner_label = _resolve_owner_label(
db, cert.tenant_no, cert.owner_type, cert.owner_id
)
driver_id = cert.owner_id if cert.owner_type == "personnel" else None
driver_name = owner_label if cert.owner_type == "personnel" else None
if existing:
existing.owner_type = cert.owner_type
existing.owner_id = cert.owner_id
existing.owner_label = owner_label
existing.driver_id = driver_id
existing.driver_name = driver_name
existing.document_type = cert.cert_name
existing.document_no = cert.cert_no
existing.expire_date = cert.expire_date
existing.days_remaining = days_remaining
continue
reminder = PlDocumentReminder(
id=generate_uuid(),
tenant_no=cert.tenant_no,
cert_id=cert.id,
owner_type=cert.owner_type,
owner_id=cert.owner_id,
owner_label=owner_label,
driver_id=driver_id,
driver_name=driver_name,
document_type=cert.cert_name,
document_no=cert.cert_no,
expire_date=cert.expire_date,
days_remaining=days_remaining,
status="pending",
create_by=cert.create_by or "system",
create_dept_id=cert.create_dept_id or "",
)
db.add(reminder)
created_count += 1
# 软删/作废/无有效期的证件:关闭仍挂着的 pending
stale_q = db.query(PlDocumentReminder).filter(
PlDocumentReminder.status == "pending",
PlDocumentReminder.del_flag == 0,
PlDocumentReminder.cert_id.isnot(None),
)
if tenant_no:
stale_q = stale_q.filter(PlDocumentReminder.tenant_no == tenant_no)
for reminder in stale_q.all():
if reminder.cert_id in active_cert_ids:
continue
_resolve_pending_reminder(
reminder,
remark="系统自动关闭:关联证件已删除或失效",
)
return created_count