Browse Source

feat(pl): 三方粘贴识别批量提交接口

新增 paste-recognize/commit,事务内处理车/人冲突策略与关联写入。

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 months ago
parent
commit
c60ebfc082

+ 27 - 0
backend/app/api/pl/base_data.py

@@ -60,6 +60,8 @@ from app.services.pl_personnel_service import (
     load_vehicle_personnel_summaries,
     save_vehicle_personnel,
 )
+from app.schemas.pl.paste_recognize import PasteCommitBody, PasteCommitResult
+from app.services.pl_paste_recognize_service import commit_paste_recognize
 
 router = APIRouter(prefix="", tags=["运输计划-基础数据"])
 
@@ -1005,3 +1007,28 @@ def unbind_vehicle_driver(
         assert_row_deletable_by_creator(vd, user_info_from_context(user))
         vd.del_flag = 1
         db.commit()
+
+
+@router.post("/paste-recognize/commit", response_model=PasteCommitResult)
+def paste_recognize_commit(
+    payload: PasteCommitBody,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> PasteCommitResult:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    try:
+        result = commit_paste_recognize(
+            db,
+            tenant_no,
+            payload,
+            user_id=user.id,
+            dept_id=user.dept_id,
+        )
+        db.commit()
+        return result
+    except HTTPException:
+        db.rollback()
+        raise
+    except Exception:
+        db.rollback()
+        raise

+ 50 - 0
backend/app/schemas/pl/paste_recognize.py

@@ -0,0 +1,50 @@
+"""运输计划 - 三方粘贴识别提交 Schema"""
+from typing import Literal, Optional
+
+from pydantic import BaseModel, Field
+
+
+class ConflictAction(BaseModel):
+    key: Literal["vehicle", "driver", "escort"]
+    existing_id: str = Field(alias="existingId")
+    action: Literal["reuse", "overwrite", "cancel"]
+
+    model_config = {"populate_by_name": True}
+
+
+class PastePersonnelIn(BaseModel):
+    personnel_name: str = Field(alias="personnelName")
+    phone_no: Optional[str] = Field(default=None, alias="phoneNo")
+    id_card_no: Optional[str] = Field(default=None, alias="idCardNo")
+    personnel_type: Literal["driver", "escort"] = Field(alias="personnelType")
+
+    model_config = {"populate_by_name": True}
+
+
+class PasteVehicleIn(BaseModel):
+    license_plate: str = Field(alias="licensePlate")
+    trailer_no: Optional[str] = Field(default=None, alias="trailerNo")
+    load_capacity: Optional[float] = Field(default=None, alias="loadCapacity")
+    carrier_id: str = Field(alias="carrierId")
+    carrier_name: Optional[str] = Field(default=None, alias="carrierName")
+
+    model_config = {"populate_by_name": True}
+
+
+class PasteCommitBody(BaseModel):
+    mode: Literal["vehicle_bundle", "personnel_first"]
+    create_vehicle: bool = Field(default=True, alias="createVehicle")
+    vehicle: Optional[PasteVehicleIn] = None
+    driver: Optional[PastePersonnelIn] = None
+    escort: Optional[PastePersonnelIn] = None
+    conflicts: list[ConflictAction] = Field(default_factory=list)
+
+    model_config = {"populate_by_name": True}
+
+
+class PasteCommitResult(BaseModel):
+    vehicle_id: Optional[str] = Field(default=None, alias="vehicleId")
+    driver_id: Optional[str] = Field(default=None, alias="driverId")
+    escort_id: Optional[str] = Field(default=None, alias="escortId")
+
+    model_config = {"populate_by_name": True, "by_alias": True}

+ 317 - 0
backend/app/services/pl_paste_recognize_service.py

@@ -0,0 +1,317 @@
+"""运输计划 - 三方粘贴识别批量提交"""
+from __future__ import annotations
+
+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 PlCarrier, PlPersonnel, PlVehicle
+from app.schemas.pl.base_data import VehiclePersonnelRow
+from app.schemas.pl.paste_recognize import (
+    ConflictAction,
+    PasteCommitBody,
+    PasteCommitResult,
+    PastePersonnelIn,
+    PasteVehicleIn,
+)
+from app.services.pl_personnel_service import save_vehicle_personnel
+
+
+def _conflict_map(conflicts: list[ConflictAction]) -> dict[str, ConflictAction]:
+    result: dict[str, ConflictAction] = {}
+    for item in conflicts:
+        key = (item.key or "").strip()
+        if not key:
+            continue
+        if key in result:
+            raise HTTPException(status_code=400, detail=f"冲突项「{key}」重复")
+        result[key] = item
+    return result
+
+
+def _find_personnel_by_id_card(
+    db: Session, tenant_no: str, id_card_no: Optional[str]
+) -> Optional[PlPersonnel]:
+    card = (id_card_no or "").strip().upper()
+    if not card:
+        return None
+    return (
+        db.query(PlPersonnel)
+        .filter(
+            PlPersonnel.tenant_no == tenant_no,
+            PlPersonnel.id_card_no == card,
+            PlPersonnel.del_flag == 0,
+        )
+        .first()
+    )
+
+
+def _find_vehicle_by_plate(
+    db: Session, tenant_no: str, license_plate: str
+) -> Optional[PlVehicle]:
+    plate = (license_plate or "").strip()
+    if not plate:
+        return None
+    return (
+        db.query(PlVehicle)
+        .filter(
+            PlVehicle.tenant_no == tenant_no,
+            PlVehicle.license_plate == plate,
+            PlVehicle.del_flag == 0,
+        )
+        .first()
+    )
+
+
+def _get_personnel(
+    db: Session, tenant_no: str, personnel_id: str
+) -> PlPersonnel:
+    row = (
+        db.query(PlPersonnel)
+        .filter(
+            PlPersonnel.id == personnel_id,
+            PlPersonnel.tenant_no == tenant_no,
+            PlPersonnel.del_flag == 0,
+        )
+        .first()
+    )
+    if not row:
+        raise HTTPException(status_code=400, detail=f"人员不存在:{personnel_id}")
+    return row
+
+
+def _get_vehicle(db: Session, tenant_no: str, vehicle_id: str) -> PlVehicle:
+    row = (
+        db.query(PlVehicle)
+        .filter(
+            PlVehicle.id == vehicle_id,
+            PlVehicle.tenant_no == tenant_no,
+            PlVehicle.del_flag == 0,
+        )
+        .first()
+    )
+    if not row:
+        raise HTTPException(status_code=400, detail=f"车辆不存在:{vehicle_id}")
+    return row
+
+
+def _resolve_personnel(
+    db: Session,
+    tenant_no: str,
+    *,
+    key: str,
+    draft: PastePersonnelIn,
+    conflict: Optional[ConflictAction],
+    user_id: str,
+    dept_id: str,
+) -> str:
+    name = (draft.personnel_name or "").strip()
+    if not name:
+        raise HTTPException(status_code=400, detail=f"{key}姓名不能为空")
+
+    existing = _find_personnel_by_id_card(db, tenant_no, draft.id_card_no)
+    if existing and conflict is None:
+        raise HTTPException(
+            status_code=400,
+            detail=f"人员「{name}」身份证已存在,请先选择复用/覆盖/取消",
+        )
+    if conflict is not None:
+        if conflict.action == "cancel":
+            raise HTTPException(status_code=400, detail="已取消")
+        target = _get_personnel(db, tenant_no, conflict.existing_id)
+        if conflict.action == "reuse":
+            return target.id
+        # overwrite
+        target.personnel_name = name
+        target.phone_no = (draft.phone_no or "").strip() or None
+        card = (draft.id_card_no or "").strip().upper() or None
+        if card:
+            target.id_card_no = card
+        target.personnel_type = draft.personnel_type
+        target.personnel_affiliation = "carrier"
+        target.update_by = user_id
+        return target.id
+
+    personnel = PlPersonnel(
+        id=generate_uuid(),
+        tenant_no=tenant_no,
+        personnel_name=name,
+        phone_no=(draft.phone_no or "").strip() or None,
+        id_card_no=(draft.id_card_no or "").strip().upper() or None,
+        personnel_type=draft.personnel_type,
+        personnel_affiliation="carrier",
+        status="1",
+        create_by=user_id,
+        create_dept_id=dept_id,
+    )
+    db.add(personnel)
+    db.flush()
+    return personnel.id
+
+
+def _resolve_vehicle(
+    db: Session,
+    tenant_no: str,
+    *,
+    draft: PasteVehicleIn,
+    conflict: Optional[ConflictAction],
+    user_id: str,
+    dept_id: str,
+) -> str:
+    plate = (draft.license_plate or "").strip()
+    if not plate:
+        raise HTTPException(status_code=400, detail="车牌号不能为空")
+    carrier_id = (draft.carrier_id or "").strip()
+    if not carrier_id:
+        raise HTTPException(status_code=400, detail="请选择承运商")
+
+    carrier = (
+        db.query(PlCarrier)
+        .filter(
+            PlCarrier.id == carrier_id,
+            PlCarrier.tenant_no == tenant_no,
+            PlCarrier.del_flag == 0,
+        )
+        .first()
+    )
+    if not carrier:
+        raise HTTPException(status_code=400, detail="承运商不存在")
+
+    carrier_name = (draft.carrier_name or "").strip() or carrier.carrier_name
+    existing = _find_vehicle_by_plate(db, tenant_no, plate)
+    if existing and conflict is None:
+        raise HTTPException(
+            status_code=400,
+            detail=f"车牌「{plate}」已存在,请先选择复用/覆盖/取消",
+        )
+    if conflict is not None:
+        if conflict.action == "cancel":
+            raise HTTPException(status_code=400, detail="已取消")
+        target = _get_vehicle(db, tenant_no, conflict.existing_id)
+        if conflict.action == "reuse":
+            return target.id
+        target.trailer_no = (draft.trailer_no or "").strip() or None
+        target.load_capacity = draft.load_capacity
+        target.carrier_id = carrier_id
+        target.carrier_name = carrier_name
+        target.vehicle_type = "carrier"
+        target.update_by = user_id
+        return target.id
+
+    vehicle = PlVehicle(
+        id=generate_uuid(),
+        tenant_no=tenant_no,
+        vehicle_type="carrier",
+        carrier_id=carrier_id,
+        carrier_name=carrier_name,
+        license_plate=plate,
+        trailer_no=(draft.trailer_no or "").strip() or None,
+        load_capacity=draft.load_capacity,
+        vehicle_status="available",
+        create_by=user_id,
+        create_dept_id=dept_id,
+    )
+    db.add(vehicle)
+    db.flush()
+    return vehicle.id
+
+
+def commit_paste_recognize(
+    db: Session,
+    tenant_no: str,
+    body: PasteCommitBody,
+    *,
+    user_id: str,
+    dept_id: str,
+) -> PasteCommitResult:
+    cmap = _conflict_map(body.conflicts or [])
+    for item in cmap.values():
+        if item.action == "cancel":
+            raise HTTPException(status_code=400, detail="已取消")
+
+    need_vehicle = body.mode == "vehicle_bundle" or (
+        body.mode == "personnel_first" and body.create_vehicle
+    )
+    if need_vehicle and body.vehicle is None:
+        raise HTTPException(status_code=400, detail="缺少车辆信息")
+
+    driver_id: Optional[str] = None
+    escort_id: Optional[str] = None
+    vehicle_id: Optional[str] = None
+
+    if body.driver is not None:
+        driver_id = _resolve_personnel(
+            db,
+            tenant_no,
+            key="驾驶员",
+            draft=body.driver,
+            conflict=cmap.get("driver"),
+            user_id=user_id,
+            dept_id=dept_id,
+        )
+    if body.escort is not None:
+        escort_id = _resolve_personnel(
+            db,
+            tenant_no,
+            key="押运员",
+            draft=body.escort,
+            conflict=cmap.get("escort"),
+            user_id=user_id,
+            dept_id=dept_id,
+        )
+
+    if need_vehicle and body.vehicle is not None:
+        vehicle_id = _resolve_vehicle(
+            db,
+            tenant_no,
+            draft=body.vehicle,
+            conflict=cmap.get("vehicle"),
+            user_id=user_id,
+            dept_id=dept_id,
+        )
+        rows: list[VehiclePersonnelRow] = []
+        if driver_id and body.driver is not None:
+            rows.append(
+                VehiclePersonnelRow(
+                    personnelId=driver_id,
+                    personnelName=body.driver.personnel_name.strip(),
+                    phoneNo=(body.driver.phone_no or "").strip() or None,
+                    role="driver",
+                    isDefault=1,
+                )
+            )
+        if escort_id and body.escort is not None:
+            rows.append(
+                VehiclePersonnelRow(
+                    personnelId=escort_id,
+                    personnelName=body.escort.personnel_name.strip(),
+                    phoneNo=(body.escort.phone_no or "").strip() or None,
+                    role="escort",
+                    isDefault=0,
+                )
+            )
+        if rows:
+            save_vehicle_personnel(
+                db,
+                vehicle_id,
+                rows,
+                tenant_no,
+                user_id=user_id,
+                dept_id=dept_id,
+                vehicle_type="carrier",
+            )
+
+    if body.mode == "personnel_first" and not need_vehicle:
+        if not driver_id and not escort_id:
+            raise HTTPException(status_code=400, detail="请至少识别一名人员")
+
+    if body.mode == "vehicle_bundle" and not vehicle_id:
+        raise HTTPException(status_code=400, detail="车辆创建失败")
+
+    return PasteCommitResult(
+        vehicleId=vehicle_id,
+        driverId=driver_id,
+        escortId=escort_id,
+    )

+ 44 - 0
web-admin/src/api/pl/index.ts

@@ -205,6 +205,50 @@ export const personnelCertOcrApi = {
     request.post<PersonnelCertOcrResult>('/transport/ocr/recognize-cert', data)
 }
 
+export type PasteConflictAction = 'reuse' | 'overwrite' | 'cancel'
+export type PasteRecognizeMode = 'vehicle_bundle' | 'personnel_first'
+
+export interface PasteConflictItem {
+  key: 'vehicle' | 'driver' | 'escort'
+  existingId: string
+  action: PasteConflictAction
+}
+
+export interface PastePersonnelPayload {
+  personnelName: string
+  phoneNo?: string
+  idCardNo?: string
+  personnelType: 'driver' | 'escort'
+}
+
+export interface PasteVehiclePayload {
+  licensePlate: string
+  trailerNo?: string
+  loadCapacity?: number | null
+  carrierId: string
+  carrierName?: string
+}
+
+export interface PasteCommitBody {
+  mode: PasteRecognizeMode
+  createVehicle?: boolean
+  vehicle?: PasteVehiclePayload | null
+  driver?: PastePersonnelPayload | null
+  escort?: PastePersonnelPayload | null
+  conflicts?: PasteConflictItem[]
+}
+
+export interface PasteCommitResult {
+  vehicleId?: string | null
+  driverId?: string | null
+  escortId?: string | null
+}
+
+export const pasteRecognizeApi = {
+  commit: (data: PasteCommitBody) =>
+    request.post<PasteCommitResult>('/transport/paste-recognize/commit', data)
+}
+
 // 运输通用地址库
 export interface TransportAddress {
   id: string