274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""运输计划 - 人员 upsert 与车辆-人员关联保存"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.id_card import calc_age_from_chinese_id_card
|
|
from app.core.utils import generate_uuid
|
|
from app.models.pl_models import PlPersonnel, PlVehiclePersonnel
|
|
from app.schemas.pl.base_data import VehiclePersonnelRow
|
|
|
|
|
|
def _prepare_personnel_rows(db: Session, tenant_no: str, rows: list[VehiclePersonnelRow]):
|
|
"""解析整批人员身份及字段,不写 ORM;缺 ID 的歧义不能靠顺序消除。"""
|
|
ids = {(row.personnel_id or "").strip() for row in rows if row.personnel_id}
|
|
names = {(row.personnel_name or "").strip() for row in rows}
|
|
phones = {(row.phone_no or "").strip() for row in rows if row.phone_no}
|
|
people = db.query(PlPersonnel).filter(
|
|
PlPersonnel.tenant_no == tenant_no, PlPersonnel.del_flag == 0,
|
|
or_(PlPersonnel.id.in_(ids), PlPersonnel.personnel_name.in_(names),
|
|
PlPersonnel.phone_no.in_(phones)),
|
|
).with_for_update().all() if rows else []
|
|
by_id = {person.id: person for person in people}
|
|
aliases = {person.id: {"name": {person.personnel_name.strip()},
|
|
"phone": {(person.phone_no or "").strip()}} for person in people}
|
|
jobs, resolved = {}, []
|
|
|
|
def merge_fields(person, item):
|
|
fields = {}
|
|
if 'personnel_name' in item.model_fields_set:
|
|
name = (item.personnel_name or '').strip()
|
|
if not name:
|
|
raise HTTPException(422, '人员姓名不能为空')
|
|
fields['personnel_name'] = name
|
|
if 'phone_no' in item.model_fields_set:
|
|
fields['phone_no'] = (item.phone_no or '').strip() or None
|
|
if 'age' in item.model_fields_set:
|
|
fields['age'] = item.age
|
|
previous = jobs.setdefault(person.id, (person, {}))[1]
|
|
if any(field in previous and previous[field] != value for field, value in fields.items()):
|
|
raise HTTPException(422, '同一人员的姓名、手机号或年龄提交冲突,请核对后保存')
|
|
previous.update(fields)
|
|
aliases[person.id]['name'].add(fields.get('personnel_name', ''))
|
|
aliases[person.id]['phone'].add(fields.get('phone_no') or '')
|
|
|
|
# 显式身份全部先校验并预占;其他角色可复用同一个人,不能重复同一角色。
|
|
reserved = set()
|
|
for item in rows:
|
|
identity = (item.personnel_id or '').strip()
|
|
if not identity:
|
|
continue
|
|
person = by_id.get(identity)
|
|
if person is None:
|
|
raise HTTPException(422, '人员 id 无效、已删除或不属于当前租户')
|
|
key = identity, item.role
|
|
if key in reserved:
|
|
raise HTTPException(422, '同一人员角色不能重复提交')
|
|
reserved.add(key)
|
|
merge_fields(person, item)
|
|
|
|
seen = set()
|
|
for item in rows:
|
|
identity = (item.personnel_id or '').strip()
|
|
person = by_id.get(identity)
|
|
if not identity:
|
|
phone = (item.phone_no or '').strip()
|
|
name = (item.personnel_name or '').strip()
|
|
candidates = [by_id[pid] for pid, keys in aliases.items() if phone and phone in keys['phone']]
|
|
if not candidates:
|
|
candidates = [by_id[pid] for pid, keys in aliases.items() if name and name in keys['name']]
|
|
if len(candidates) > 1:
|
|
raise HTTPException(422, '姓名或手机号对应多名人员,请明确选择人员 ID 后保存')
|
|
person = candidates[0] if candidates else None
|
|
if person is None:
|
|
if not name:
|
|
raise HTTPException(422, '新增人员须提供姓名')
|
|
# 未加入 Session,整批验证失败不会留下部分新增的人员。
|
|
person = PlPersonnel(id=generate_uuid(), tenant_no=tenant_no, personnel_name=name)
|
|
by_id[person.id] = person
|
|
aliases[person.id] = {"name": {name}, "phone": {phone}}
|
|
if (person.id, item.role) in reserved:
|
|
raise HTTPException(422, '缺少人员 ID 的记录重复引用已提交人员角色')
|
|
if item.role not in ('driver', 'escort'):
|
|
raise HTTPException(422, '人员角色必须为司机或押运员')
|
|
key = person.id, item.role
|
|
if key in seen:
|
|
raise HTTPException(422, '同一人员角色不能重复提交')
|
|
seen.add(key)
|
|
merge_fields(person, item)
|
|
resolved.append((item, person))
|
|
return resolved, jobs
|
|
|
|
|
|
def _apply_personnel_jobs(db: Session, jobs, *, user_id: str, dept_id: str, vehicle_type: str) -> None:
|
|
affiliation = vehicle_type if vehicle_type in ('self', 'carrier') else 'self'
|
|
for person, fields in jobs.values():
|
|
if person not in db:
|
|
person.personnel_type = 'both'
|
|
person.personnel_affiliation = affiliation
|
|
person.status = '1'
|
|
person.create_by = user_id
|
|
person.create_dept_id = dept_id
|
|
db.add(person)
|
|
for field, value in fields.items():
|
|
setattr(person, field, value)
|
|
person.update_by = user_id
|
|
|
|
|
|
def upsert_personnel(
|
|
db: Session,
|
|
tenant_no: str,
|
|
row: VehiclePersonnelRow,
|
|
*,
|
|
user_id: str,
|
|
dept_id: str,
|
|
vehicle_type: str = "self",
|
|
) -> str:
|
|
"""兼容单人入口,与车辆集合使用相同的唯一身份匹配规则。"""
|
|
with db.no_autoflush:
|
|
resolved, jobs = _prepare_personnel_rows(db, tenant_no, [row])
|
|
_apply_personnel_jobs(db, jobs, user_id=user_id, dept_id=dept_id, vehicle_type=vehicle_type)
|
|
db.flush()
|
|
return resolved[0][1].id
|
|
|
|
|
|
def save_vehicle_personnel(
|
|
db: Session,
|
|
vehicle_id: str,
|
|
rows: list[VehiclePersonnelRow],
|
|
tenant_no: str,
|
|
*,
|
|
user_id: str,
|
|
dept_id: str,
|
|
vehicle_type: str = "self",
|
|
) -> None:
|
|
"""按人员 + 角色差量保存;整批身份、字段及最终默认司机先验证。"""
|
|
from app.services.pl_child_sync import prepare_keyed_rows, apply_keyed_rows
|
|
if rows is None:
|
|
return
|
|
with db.no_autoflush:
|
|
existing = db.query(PlVehiclePersonnel).filter(PlVehiclePersonnel.vehicle_id == vehicle_id,
|
|
PlVehiclePersonnel.tenant_no == tenant_no, PlVehiclePersonnel.del_flag == 0).with_for_update().all()
|
|
resolved, jobs = _prepare_personnel_rows(db, tenant_no, rows)
|
|
current = {(row.personnel_id, row.role): row for row in existing}
|
|
values, defaults = [], 0
|
|
for item, person in resolved:
|
|
previous = current.get((person.id, item.role))
|
|
is_default = item.is_default if 'is_default' in item.model_fields_set else (
|
|
previous.is_default if previous is not None else 0)
|
|
if item.role == 'escort' and is_default == 1:
|
|
raise HTTPException(400, '仅司机角色可设为默认')
|
|
defaults += int(item.role == 'driver' and is_default == 1)
|
|
value = dict(tenant_no=tenant_no, vehicle_id=vehicle_id, personnel_id=person.id,
|
|
role=item.role, is_default=is_default)
|
|
if 'remark' in item.model_fields_set:
|
|
value['remark'] = item.remark
|
|
values.append(value)
|
|
if defaults > 1:
|
|
raise HTTPException(400, '每辆车最多设置一名默认司机')
|
|
prepared = prepare_keyed_rows(existing, values, ('personnel_id', 'role'), '车辆人员关联')
|
|
_apply_personnel_jobs(db, jobs, user_id=user_id, dept_id=dept_id, vehicle_type=vehicle_type)
|
|
apply_keyed_rows(db, PlVehiclePersonnel, prepared, user_id=user_id, dept_id=dept_id)
|
|
|
|
|
|
def build_personnel_summary(
|
|
links: list[tuple[str, str, int]],
|
|
) -> tuple[str, Optional[str]]:
|
|
"""links: [(personnel_name, role, is_default)] → (summary, default_driver_name)."""
|
|
role_labels = {"driver": "驾", "escort": "押"}
|
|
parts: list[str] = []
|
|
default_name: Optional[str] = None
|
|
|
|
for name, role, is_default in links:
|
|
label = role_labels.get(role, role)
|
|
parts.append(f"{name}({label})")
|
|
if role == "driver" and int(is_default) == 1:
|
|
default_name = name
|
|
|
|
return "、".join(parts), default_name
|
|
|
|
|
|
def load_vehicle_personnel_rows(
|
|
db: Session, tenant_no: str, vehicle_id: str
|
|
) -> list[VehiclePersonnelRow]:
|
|
"""查询车辆关联人员子表行。"""
|
|
rows = (
|
|
db.query(PlVehiclePersonnel, PlPersonnel)
|
|
.join(
|
|
PlPersonnel,
|
|
PlPersonnel.id == PlVehiclePersonnel.personnel_id,
|
|
)
|
|
.filter(
|
|
PlVehiclePersonnel.vehicle_id == vehicle_id,
|
|
PlVehiclePersonnel.tenant_no == tenant_no,
|
|
PlVehiclePersonnel.del_flag == 0,
|
|
PlPersonnel.del_flag == 0,
|
|
)
|
|
.order_by(PlVehiclePersonnel.order_num.asc(), PlVehiclePersonnel.create_time.asc())
|
|
.all()
|
|
)
|
|
return [
|
|
VehiclePersonnelRow(
|
|
personnel_id=p.personnel_id,
|
|
personnel_name=person.personnel_name,
|
|
phone_no=person.phone_no,
|
|
age=person.age,
|
|
role=p.role,
|
|
is_default=p.is_default,
|
|
remark=p.remark,
|
|
)
|
|
for p, person in rows
|
|
]
|
|
|
|
|
|
def load_vehicle_personnel_summaries(
|
|
db: Session, tenant_no: str, vehicle_ids: list[str]
|
|
) -> dict[str, tuple[str, Optional[str]]]:
|
|
"""批量加载 personnelSummary / defaultDriverName。"""
|
|
if not vehicle_ids:
|
|
return {}
|
|
|
|
rows = (
|
|
db.query(
|
|
PlVehiclePersonnel.vehicle_id,
|
|
PlPersonnel.personnel_name,
|
|
PlVehiclePersonnel.role,
|
|
PlVehiclePersonnel.is_default,
|
|
)
|
|
.join(PlPersonnel, PlPersonnel.id == PlVehiclePersonnel.personnel_id)
|
|
.filter(
|
|
PlVehiclePersonnel.vehicle_id.in_(vehicle_ids),
|
|
PlVehiclePersonnel.tenant_no == tenant_no,
|
|
PlVehiclePersonnel.del_flag == 0,
|
|
PlPersonnel.del_flag == 0,
|
|
)
|
|
.order_by(PlVehiclePersonnel.order_num.asc())
|
|
.all()
|
|
)
|
|
|
|
grouped: dict[str, list[tuple[str, str, str]]] = {}
|
|
for vehicle_id, name, role, is_default in rows:
|
|
grouped.setdefault(vehicle_id, []).append((name, role, is_default))
|
|
|
|
return {
|
|
vid: build_personnel_summary(items) for vid, items in grouped.items()
|
|
}
|
|
|
|
|
|
def refresh_personnel_ages_from_id_card(db: Session) -> int:
|
|
"""按 18 位身份证号重算 pl_personnel.age,仅更新算出值与库中不一致的记录。返回更新条数。"""
|
|
rows = (
|
|
db.query(PlPersonnel)
|
|
.filter(
|
|
PlPersonnel.del_flag == 0,
|
|
PlPersonnel.id_card_no.isnot(None),
|
|
PlPersonnel.id_card_no != "",
|
|
)
|
|
.all()
|
|
)
|
|
now = datetime.now()
|
|
updated = 0
|
|
for row in rows:
|
|
age = calc_age_from_chinese_id_card(row.id_card_no)
|
|
if age is None or row.age == age:
|
|
continue
|
|
row.age = age
|
|
row.update_time = now
|
|
updated += 1
|
|
return updated
|