feat(pl): 三方粘贴识别批量提交接口
新增 paste-recognize/commit,事务内处理车/人冲突策略与关联写入。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
b1e788e856
commit
c60ebfc082
4 files changed
+438
No files matched your search
@@ -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
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user