From c60ebfc0827e192a70a8fa702d831cb0285befe1 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sat, 25 Jul 2026 12:52:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(pl):=20=E4=B8=89=E6=96=B9=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E8=AF=86=E5=88=AB=E6=89=B9=E9=87=8F=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 paste-recognize/commit,事务内处理车/人冲突策略与关联写入。 Co-authored-by: Cursor --- backend/app/api/pl/base_data.py | 27 ++ backend/app/schemas/pl/paste_recognize.py | 50 +++ .../services/pl_paste_recognize_service.py | 317 ++++++++++++++++++ web-admin/src/api/pl/index.ts | 44 +++ 4 files changed, 438 insertions(+) create mode 100644 backend/app/schemas/pl/paste_recognize.py create mode 100644 backend/app/services/pl_paste_recognize_service.py diff --git a/backend/app/api/pl/base_data.py b/backend/app/api/pl/base_data.py index 5de5a54bd..ba0421e7c 100644 --- a/backend/app/api/pl/base_data.py +++ b/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 diff --git a/backend/app/schemas/pl/paste_recognize.py b/backend/app/schemas/pl/paste_recognize.py new file mode 100644 index 000000000..7ec9f3239 --- /dev/null +++ b/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} diff --git a/backend/app/services/pl_paste_recognize_service.py b/backend/app/services/pl_paste_recognize_service.py new file mode 100644 index 000000000..2c89db3df --- /dev/null +++ b/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, + ) diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index 9328e2e8a..84552fbb1 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -205,6 +205,50 @@ export const personnelCertOcrApi = { request.post('/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('/transport/paste-recognize/commit', data) +} + // 运输通用地址库 export interface TransportAddress { id: string