| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314 |
- """运输计划管理 - 运输 API"""
- from datetime import date, datetime
- from typing import List, Optional
- import json
- import secrets
- from fastapi import APIRouter, Depends, HTTPException, Query, status
- from pydantic import BaseModel, Field
- from sqlalchemy import func, or_
- from sqlalchemy.orm import Session
- from app.core.database import get_db
- from app.core.db_auth import DbUserContext, get_db_current_user
- from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
- from app.core.tenant_context import get_tenant_no
- from app.utils.excel_export import (
- EXPORT_MAX_ROWS,
- ExportColumn,
- ExportValidationError,
- export_rows_to_xlsx_response,
- filter_export_columns,
- )
- from app.core.utils import generate_uuid
- from app.core.config import settings
- from app.models.pl_models import (
- PlCarrier,
- PlDriver,
- PlPersonnel,
- PlPlan,
- PlPlanProduct,
- PlPlanTransport,
- PlTransport,
- PlTransportApproval,
- PlTransportDbLink,
- PlVehicle,
- )
- from app.schemas.pl.io_order import BrandQtyItem
- from app.schemas.pl.transport import (
- ApprovalRecordRead,
- ApprovalRequest,
- ApprovalSubmit,
- TransportArrange,
- TransportArrangeResult,
- TransportCreate,
- TransportFeedback,
- TransportInventoryLinkRead,
- TransportIoOrderBrief,
- TransportSiteLinkRead,
- TransportListQuery,
- TransportListResponse,
- TransportDriverProductFeedbackRead,
- TransportDriverStopProductFeedbackRead,
- TransportRead,
- TransportStopFeedbackRead,
- )
- from app.services.pl_dibang_bridge_service import (
- StopDibangOption,
- plan_has_load_stop,
- revoke_regeneratable_dibang_for_transport,
- sync_dibang_for_plan_stops,
- sync_dibang_for_transport,
- )
- from app.services.pl_transport_stop_feedback_service import (
- build_transport_site_url,
- ensure_transport_site_token,
- load_driver_stop_segments,
- load_site_stop_segments,
- rotate_transport_site_token,
- )
- from app.services.pl_inventory_link_read import build_transport_inventory_link_read
- from app.services.pl_io_order_service import (
- apply_dibang_prefill_to_order,
- create_io_order_for_transport,
- void_io_orders_for_plan,
- )
- from app.services.pl_internal_customer import plan_requires_site_link
- from app.services.pl_plan_service import (
- validate_hazard_cert_ready_for_transport,
- )
- from app.services.pl_transport_resource_snapshot_service import (
- apply_resource_snapshot_json,
- build_transport_resource_snapshot,
- )
- from app.services.pl_carrier_service import assert_carrier_eligible_for_transport
- from app.services.pl_feedback_brand_qty import load_transport_driver_product_feedbacks
- router = APIRouter(prefix="/order", tags=["运输计划-运输管理"])
- def _transport_to_read(db: Session, transport: PlTransport) -> TransportRead:
- base = TransportRead.model_validate(transport)
- feedbacks = load_transport_driver_product_feedbacks(
- db,
- tenant_no=transport.tenant_no,
- transport_id=transport.id,
- )
- updates: dict = {}
- if feedbacks:
- updates["driver_product_feedbacks"] = [
- TransportDriverProductFeedbackRead.model_validate(item)
- for item in feedbacks
- ]
- link = (
- db.query(PlPlanTransport)
- .filter(
- PlPlanTransport.transport_id == transport.id,
- PlPlanTransport.del_flag == 0,
- )
- .order_by(PlPlanTransport.create_time.desc())
- .first()
- )
- if link and link.plan_id and plan_has_load_stop(db, transport.tenant_no, link.plan_id):
- updates["driver_stop_feedbacks"] = [
- TransportStopFeedbackRead(
- stop_type=segment.stop_type,
- stop_id=segment.stop_id,
- label=segment.label,
- customer_name=segment.customer_name,
- attachment_urls=segment.attachment_urls,
- products=[
- TransportDriverStopProductFeedbackRead(
- stop_product_id=product.stop_product_id,
- plan_product_id=product.plan_product_id,
- product_name=product.product_name,
- model=product.model,
- unit=product.unit,
- package_spec=product.package_spec,
- quantity=product.plan_qty,
- actual_qty=product.actual_qty,
- brand_qtys=[
- BrandQtyItem(
- brand_id=str(item.get("brandId") or item.get("brand_id") or ""),
- brand_name=item.get("brandName") or item.get("brand_name"),
- qty=item.get("qty") or 0,
- )
- for item in (product.brand_qtys or [])
- if isinstance(item, dict)
- ]
- or None,
- )
- for product in segment.products
- if product.actual_qty is not None
- or (product.brand_qtys and len(product.brand_qtys) > 0)
- ],
- remark=segment.remark,
- feedback_time=segment.feedback_time,
- )
- for segment in load_driver_stop_segments(
- db,
- tenant_no=transport.tenant_no,
- plan_id=link.plan_id,
- transport_id=transport.id,
- )
- if segment.attachment_urls
- or segment.remark
- or segment.feedback_time
- or any(
- product.actual_qty is not None
- or (product.brand_qtys and len(product.brand_qtys) > 0)
- for product in segment.products
- )
- ]
- updates["site_stop_feedbacks"] = [
- TransportStopFeedbackRead(
- stop_type=segment.stop_type,
- stop_id=segment.stop_id,
- label=segment.label,
- customer_name=segment.customer_name,
- business_type=segment.business_type,
- quantity=segment.quantity,
- attachment_urls=segment.attachment_urls,
- products=[
- TransportDriverStopProductFeedbackRead(
- stop_product_id=product.stop_product_id,
- plan_product_id=product.plan_product_id,
- product_name=product.product_name,
- model=product.model,
- unit=product.unit,
- package_spec=product.package_spec,
- quantity=product.plan_qty,
- actual_qty=product.actual_qty,
- brand_qtys=[
- BrandQtyItem(
- brand_id=str(item.get("brandId") or item.get("brand_id") or ""),
- brand_name=item.get("brandName") or item.get("brand_name"),
- qty=item.get("qty") or 0,
- )
- for item in (product.brand_qtys or [])
- if isinstance(item, dict)
- ]
- or None,
- )
- for product in segment.products
- if product.actual_qty is not None
- or (product.brand_qtys and len(product.brand_qtys) > 0)
- ],
- remark=segment.remark,
- feedback_time=segment.feedback_time,
- )
- for segment in load_site_stop_segments(
- db,
- tenant_no=transport.tenant_no,
- plan_id=link.plan_id,
- transport_id=transport.id,
- )
- if segment.quantity is not None
- or segment.attachment_urls
- or segment.remark
- or any(
- product.actual_qty is not None
- or (product.brand_qtys and len(product.brand_qtys) > 0)
- for product in segment.products
- )
- ]
- if not updates:
- return base
- return base.model_copy(update=updates)
- def _generate_transport_no(db: Session) -> str:
- """生成运输编号 SXYS######"""
- now = date.today()
- prefix = f"SXYS{now.strftime('%Y%m')}"
- last = (
- db.query(PlTransport)
- .filter(
- PlTransport.transport_no.like(f"{prefix}%"),
- PlTransport.del_flag == 0,
- )
- .order_by(PlTransport.transport_no.desc())
- .first()
- )
- if last:
- last_seq = int(last.transport_no[-6:])
- seq = last_seq + 1
- else:
- seq = 1
- return f"{prefix}{seq:06d}"
- def _resolve_driver_info(db: Session, tenant_no: str, driver_id: Optional[str]) -> Optional[dict]:
- """解析司机信息:优先 pl_personnel,兼容旧 pl_driver。"""
- if not driver_id:
- return None
- personnel = (
- db.query(PlPersonnel)
- .filter(
- PlPersonnel.id == driver_id,
- PlPersonnel.tenant_no == tenant_no,
- PlPersonnel.del_flag == 0,
- )
- .first()
- )
- if personnel:
- return {
- "id": personnel.id,
- "name": personnel.personnel_name,
- "phone": personnel.phone_no,
- }
- legacy = (
- db.query(PlDriver)
- .filter(
- PlDriver.id == driver_id,
- PlDriver.tenant_no == tenant_no,
- PlDriver.del_flag == 0,
- )
- .first()
- )
- if legacy:
- return {
- "id": legacy.id,
- "name": legacy.driver_name,
- "phone": legacy.phone_no,
- }
- return None
- def _get_vehicle_info(db: Session, vehicle_id: str, tenant_no: Optional[str] = None) -> dict:
- vehicle = db.query(PlVehicle).filter(PlVehicle.id == vehicle_id).first()
- if not vehicle:
- return {}
- info = {
- "license_plate": vehicle.license_plate,
- "carrier_id": vehicle.carrier_id,
- "carrier_name": vehicle.carrier_name,
- }
- if vehicle.default_driver_id:
- driver_info = (
- _resolve_driver_info(db, tenant_no, vehicle.default_driver_id)
- if tenant_no
- else None
- )
- if not driver_info:
- personnel = db.query(PlPersonnel).filter(
- PlPersonnel.id == vehicle.default_driver_id,
- PlPersonnel.del_flag == 0,
- ).first()
- if personnel:
- driver_info = {
- "id": personnel.id,
- "name": personnel.personnel_name,
- "phone": personnel.phone_no,
- }
- else:
- legacy = db.query(PlDriver).filter(
- PlDriver.id == vehicle.default_driver_id,
- PlDriver.del_flag == 0,
- ).first()
- if legacy:
- driver_info = {
- "id": legacy.id,
- "name": legacy.driver_name,
- "phone": legacy.phone_no,
- }
- if driver_info:
- info["driver_id"] = driver_info["id"]
- info["driver_name"] = driver_info["name"]
- info["driver_phone"] = driver_info["phone"]
- elif vehicle.default_driver_name:
- info["driver_id"] = vehicle.default_driver_id
- info["driver_name"] = vehicle.default_driver_name
- return info
- def _assert_vehicle_carrier_eligible(
- db: Session,
- tenant_no: str,
- vehicle_id: str,
- *,
- rearrange: bool = False,
- ) -> None:
- vehicle = (
- db.query(PlVehicle)
- .filter(
- PlVehicle.id == vehicle_id,
- PlVehicle.tenant_no == tenant_no,
- PlVehicle.del_flag == 0,
- )
- .first()
- )
- if not vehicle:
- raise HTTPException(status_code=400, detail="车辆不存在")
- if not vehicle.carrier_id:
- return
- carrier = (
- db.query(PlCarrier)
- .filter(
- PlCarrier.id == vehicle.carrier_id,
- PlCarrier.tenant_no == tenant_no,
- PlCarrier.del_flag == 0,
- )
- .first()
- )
- if not carrier:
- raise HTTPException(status_code=400, detail="承运商不存在")
- assert_carrier_eligible_for_transport(carrier, rearrange=rearrange)
- def _ensure_driver_access_token(transport: PlTransport) -> str:
- token = (transport.driver_access_token or "").strip()
- if not token:
- token = secrets.token_urlsafe(32)[:64]
- transport.driver_access_token = token
- return token
- def _rotate_driver_access_token(transport: PlTransport) -> str:
- token = secrets.token_urlsafe(32)[:64]
- transport.driver_access_token = token
- return token
- def _is_rearrange(plan: PlPlan, existing_transport: Optional[PlTransport]) -> bool:
- """仅计划已正式安排才算「调整运输」。
- 创建/编辑表单预选车辆会留下 draft 运输单,计划仍为 pending;
- 若把非 cancelled 运输单都当成 rearrange,会把首次正式安排误判为调整。
- existing_transport 保留入参以兼容调用方;草稿复用由后续分支处理。
- """
- _ = existing_transport
- return plan.transport_status == "arranged"
- def _transport_has_db_links(db: Session, tenant_no: str, transport_id: str) -> bool:
- return (
- db.query(PlTransportDbLink.id)
- .filter(
- PlTransportDbLink.tenant_no == tenant_no,
- PlTransportDbLink.transport_id == transport_id,
- PlTransportDbLink.del_flag == 0,
- )
- .first()
- is not None
- )
- def _apply_plan_logistics_from_payload(plan: PlPlan, payload: TransportArrange) -> None:
- if payload.pickup_address is not None:
- plan.pickup_address = payload.pickup_address
- elif payload.start_point is not None:
- plan.pickup_address = payload.start_point
- if "pickup_lng" in payload.model_fields_set or "pickup_lat" in payload.model_fields_set:
- plan.pickup_lng = payload.pickup_lng
- plan.pickup_lat = payload.pickup_lat
- if payload.pickup_time is not None:
- plan.pickup_time = payload.pickup_time
- if payload.pickup_contact is not None:
- plan.pickup_contact = payload.pickup_contact
- if payload.pickup_notes is not None:
- plan.pickup_notes = payload.pickup_notes
- if payload.destination_address is not None:
- plan.destination_address = payload.destination_address
- elif payload.end_point is not None:
- plan.destination_address = payload.end_point
- if "destination_lng" in payload.model_fields_set or "destination_lat" in payload.model_fields_set:
- plan.destination_lng = payload.destination_lng
- plan.destination_lat = payload.destination_lat
- if payload.delivery_time is not None:
- plan.delivery_time = payload.delivery_time
- if payload.delivery_contact is not None:
- plan.delivery_contact = payload.delivery_contact
- if payload.destination_notes is not None:
- plan.destination_notes = payload.destination_notes
- def _resolve_transport_endpoints(plan: PlPlan, payload: TransportArrange) -> tuple[Optional[str], Optional[str]]:
- start_point = (
- payload.pickup_address
- or payload.start_point
- or plan.pickup_address
- )
- end_point = (
- payload.destination_address
- or payload.end_point
- or plan.destination_address
- or plan.destination_name
- )
- return start_point, end_point
- def _apply_transport_arrange_fields(
- transport: PlTransport,
- *,
- payload: TransportArrange,
- vehicle_info: dict,
- driver_info: Optional[dict],
- carrier_name: Optional[str],
- start_point: Optional[str],
- end_point: Optional[str],
- freight_amount: Optional[float],
- user_id: str,
- ) -> None:
- transport.vehicle_id = payload.vehicle_id
- transport.license_plate = vehicle_info.get("license_plate")
- transport.driver_id = driver_info["id"] if driver_info else None
- transport.driver_name = driver_info["name"] if driver_info else None
- transport.driver_phone = driver_info["phone"] if driver_info else None
- transport.carrier_id = payload.carrier_id or vehicle_info.get("carrier_id")
- transport.carrier_name = carrier_name
- transport.transport_medium = payload.transport_medium
- transport.start_point = start_point
- transport.end_point = end_point
- transport.distance_km = payload.distance_km
- transport.unit = payload.unit or ("吨" if payload.freight_type == "by_weight" else "趟")
- transport.freight_price = payload.freight_price
- transport.freight_amount = freight_amount or 0
- transport.freight_type = payload.freight_type
- if payload.has_invoice is not None:
- transport.has_invoice = payload.has_invoice
- if payload.remark is not None:
- transport.remark = payload.remark
- transport.update_by = user_id
- def _build_driver_url(token: str) -> str:
- origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
- return f"{origin}/#/public/pl/transport/{token}"
- def _build_arrange_result(
- transport: PlTransport,
- site_records: list[TransportSiteLinkRead],
- inventory_records: list[TransportInventoryLinkRead],
- io_order: Optional[TransportIoOrderBrief] = None,
- contract_qty_reminder: Optional[dict] = None,
- ) -> TransportArrangeResult:
- token = transport.driver_access_token or ""
- result = TransportArrangeResult.model_validate(transport)
- result.driver_access_token = token
- result.driver_url = _build_driver_url(token) if token else None
- result.site_records = site_records
- result.inventory_records = inventory_records
- result.io_order = io_order
- if contract_qty_reminder:
- from app.schemas.pl.transport import ContractQtyReminder
- result.contract_qty_reminder = ContractQtyReminder.model_validate(contract_qty_reminder)
- return result
- # =============================================================================
- # 运输列表
- # =============================================================================
- @router.get("/", response_model=TransportListResponse)
- def list_transports(
- q: Optional[str] = Query(default=None, description="模糊搜索"),
- transport_no: Optional[str] = Query(default=None, alias="transportNo", description="运输编号"),
- transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
- approval_status: Optional[str] = Query(default=None, alias="approvalStatus", description="审批状态"),
- vehicle_id: Optional[str] = Query(default=None, alias="vehicleId", description="车辆ID"),
- driver_id: Optional[str] = Query(default=None, alias="driverId", description="司机ID"),
- page: int = Query(default=1, ge=1),
- page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> TransportListResponse:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- query = db.query(PlTransport).filter(
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- if q:
- query = query.filter(
- or_(
- PlTransport.transport_no.contains(q),
- PlTransport.license_plate.contains(q),
- PlTransport.driver_name.contains(q),
- )
- )
- if transport_no:
- query = query.filter(PlTransport.transport_no.contains(transport_no))
- if transport_status:
- query = query.filter(PlTransport.transport_status == transport_status)
- if approval_status:
- query = query.filter(PlTransport.approval_status == approval_status)
- if vehicle_id:
- query = query.filter(PlTransport.vehicle_id == vehicle_id)
- if driver_id:
- query = query.filter(PlTransport.driver_id == driver_id)
- query = apply_pl_scope(query, PlTransport, user, db, tenant_no)
- total = query.count()
- offset = (page - 1) * page_size
- transports = query.order_by(PlTransport.create_time.desc()).offset(offset).limit(page_size).all()
- return TransportListResponse(total=total, list=transports)
- # =============================================================================
- # 创建运输
- # =============================================================================
- TRANSPORT_EXPORT_COLUMNS = [
- ExportColumn(title="运输编号", field="transportNo"),
- ExportColumn(title="车牌号", field="licensePlate"),
- ExportColumn(title="司机", field="driverName"),
- ExportColumn(title="承运商", field="carrierName"),
- ExportColumn(title="运输介质", field="transportMedium"),
- ExportColumn(title="起点", field="startPoint"),
- ExportColumn(title="终点", field="endPoint"),
- ExportColumn(title="运输状态", field="transportStatus"),
- ExportColumn(title="审批状态", field="approvalStatus"),
- ExportColumn(title="备注", field="remark"),
- ]
- class TransportExportBody(BaseModel):
- q: Optional[str] = None
- transport_no: Optional[str] = Field(default=None, alias="transportNo")
- transport_status: Optional[str] = Field(default=None, alias="transportStatus")
- approval_status: Optional[str] = Field(default=None, alias="approvalStatus")
- vehicle_id: Optional[str] = Field(default=None, alias="vehicleId")
- driver_id: Optional[str] = Field(default=None, alias="driverId")
- ids: Optional[List[str]] = None
- columns: Optional[List[str]] = None
- model_config = {"populate_by_name": True}
- @router.post("/export")
- def export_transports(
- body: TransportExportBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- """导出运输列表(当前筛选全量,或 ids 已选)。"""
- try:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- query = db.query(PlTransport).filter(
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- if body.q:
- query = query.filter(
- or_(
- PlTransport.transport_no.contains(body.q),
- PlTransport.license_plate.contains(body.q),
- PlTransport.driver_name.contains(body.q),
- )
- )
- if body.transport_no:
- query = query.filter(PlTransport.transport_no.contains(body.transport_no))
- if body.transport_status:
- query = query.filter(PlTransport.transport_status == body.transport_status)
- if body.approval_status:
- query = query.filter(PlTransport.approval_status == body.approval_status)
- if body.vehicle_id:
- query = query.filter(PlTransport.vehicle_id == body.vehicle_id)
- if body.driver_id:
- query = query.filter(PlTransport.driver_id == body.driver_id)
- if body.ids:
- query = query.filter(PlTransport.id.in_(body.ids))
- query = apply_pl_scope(query, PlTransport, user, db, tenant_no)
- rows = query.order_by(PlTransport.create_time.desc()).limit(EXPORT_MAX_ROWS + 1).all()
- items = [TransportRead.model_validate(r).model_dump(by_alias=True) for r in rows]
- cols = filter_export_columns(TRANSPORT_EXPORT_COLUMNS, body.columns)
- date_str = datetime.now().strftime("%Y%m%d")
- return export_rows_to_xlsx_response(
- db,
- tenant_no=tenant_no,
- sheet_title="运输列表",
- columns=cols,
- rows=items,
- filename_stem=f"运输列表_{date_str}",
- )
- except ExportValidationError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
- @router.post("/", response_model=TransportRead, status_code=status.HTTP_201_CREATED)
- def create_transport(
- payload: TransportCreate,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> TransportRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- transport_no = _generate_transport_no(db)
- transport = PlTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- transport_no=transport_no,
- vehicle_id=payload.vehicle_id,
- license_plate=payload.license_plate,
- driver_id=payload.driver_id,
- driver_name=payload.driver_name,
- driver_phone=payload.driver_phone,
- carrier_id=payload.carrier_id,
- carrier_name=payload.carrier_name,
- transport_medium=payload.transport_medium,
- start_point=payload.start_point,
- end_point=payload.end_point,
- distance_km=payload.distance_km,
- unit=payload.unit,
- freight_price=payload.freight_price,
- freight_amount=payload.freight_amount,
- freight_type=payload.freight_type,
- has_invoice=payload.has_invoice,
- qr_expire_date=payload.qr_expire_date,
- transport_status=payload.transport_status or "draft",
- approval_status=payload.approval_status or "none",
- remark=payload.remark,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(transport)
- # 关联计划
- if payload.plan_ids:
- for pid in payload.plan_ids:
- pt = PlPlanTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_id=pid,
- transport_id=transport.id,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(pt)
- # 更新计划关联的运输信息
- plan = db.query(PlPlan).filter(PlPlan.id == pid, PlPlan.del_flag == 0).first()
- if plan:
- assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
- plan.transport_id = transport.id
- plan.transport_no = transport_no
- plan.vehicle_id = transport.vehicle_id
- plan.license_plate = transport.license_plate
- plan.driver_id = transport.driver_id
- plan.driver_name = transport.driver_name
- if transport.vehicle_id:
- snapshot = build_transport_resource_snapshot(
- db,
- tenant_no,
- transport.vehicle_id,
- selected_driver_id=transport.driver_id,
- )
- apply_resource_snapshot_json(
- plan=plan, transport=transport, snapshot=snapshot
- )
- db.commit()
- db.refresh(transport)
- return transport
- # =============================================================================
- # 运输安排
- # =============================================================================
- @router.post("/arrange/{plan_id}", response_model=TransportArrangeResult, status_code=status.HTTP_201_CREATED)
- def arrange_transport(
- plan_id: str,
- payload: TransportArrange,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> TransportArrangeResult:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- # 获取计划
- plan = db.query(PlPlan).filter(
- PlPlan.id == plan_id,
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- ).first()
- if not plan:
- raise HTTPException(status_code=404, detail="计划不存在")
- assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
- existing_transport = None
- if plan.transport_id:
- existing_transport = db.query(PlTransport).filter(
- PlTransport.id == plan.transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- ).first()
- is_rearrange = _is_rearrange(plan, existing_transport)
- if is_rearrange and existing_transport and existing_transport.vehicle_id:
- _assert_vehicle_carrier_eligible(
- db,
- tenant_no,
- existing_transport.vehicle_id,
- rearrange=True,
- )
- _assert_vehicle_carrier_eligible(db, tenant_no, payload.vehicle_id, rearrange=False)
- # 获取车辆信息
- vehicle_info = _get_vehicle_info(db, payload.vehicle_id, tenant_no)
- # 获取司机信息(可选;driverId 为人员 pl_personnel.id)
- driver_info = None
- if payload.driver_id:
- driver_info = _resolve_driver_info(db, tenant_no, payload.driver_id)
- if not driver_info:
- raise HTTPException(status_code=400, detail="司机不存在")
- # 检查产品属性(易制毒/易制爆需要证件,以 CRM 产品主数据为准)
- products = db.query(PlPlanProduct).filter(
- PlPlanProduct.plan_id == plan_id,
- PlPlanProduct.del_flag == 0,
- ).all()
- validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
- from app.services.pl_contract_plan_qty import (
- assert_contract_qty_for_arrange,
- build_contract_qty_reminder_payload,
- )
- contract_qty_reminders = assert_contract_qty_for_arrange(
- db, tenant_no=tenant_no, plan_id=plan_id
- )
- contract_qty_reminder_payload = build_contract_qty_reminder_payload(contract_qty_reminders)
- # 计算运费
- freight_amount = payload.freight_amount
- if payload.freight_type == "by_weight" and payload.freight_price:
- total_qty = sum(
- float(p.quantity or p.inbound_estimate_qty or p.outbound_estimate_qty or 0)
- for p in products
- )
- freight_amount = total_qty * float(payload.freight_price)
- carrier_name = vehicle_info.get("carrier_name")
- if payload.carrier_id:
- carrier = db.query(PlCarrier).filter(PlCarrier.id == payload.carrier_id).first()
- if carrier:
- carrier_name = carrier.carrier_name
- _apply_plan_logistics_from_payload(plan, payload)
- start_point, end_point = _resolve_transport_endpoints(plan, payload)
- if (
- is_rearrange
- and existing_transport
- and _transport_has_db_links(db, tenant_no, existing_transport.id)
- ):
- try:
- revoke_regeneratable_dibang_for_transport(
- db,
- tenant_no=tenant_no,
- transport_id=existing_transport.id,
- user_id=user.id,
- )
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- if existing_transport and existing_transport.transport_status != "cancelled":
- transport = existing_transport
- _apply_transport_arrange_fields(
- transport,
- payload=payload,
- vehicle_info=vehicle_info,
- driver_info=driver_info,
- carrier_name=carrier_name,
- start_point=start_point,
- end_point=end_point,
- freight_amount=freight_amount,
- user_id=user.id,
- )
- transport_no = transport.transport_no
- else:
- transport_no = _generate_transport_no(db)
- transport = PlTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- transport_no=transport_no,
- transport_status="draft",
- approval_status="none",
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(transport)
- _apply_transport_arrange_fields(
- transport,
- payload=payload,
- vehicle_info=vehicle_info,
- driver_info=driver_info,
- carrier_name=carrier_name,
- start_point=start_point,
- end_point=end_point,
- freight_amount=freight_amount,
- user_id=user.id,
- )
- pt = PlPlanTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_id=plan_id,
- transport_id=transport.id,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(pt)
- plan.transport_id = transport.id
- plan.transport_no = transport_no
- plan.vehicle_id = transport.vehicle_id
- plan.license_plate = transport.license_plate
- plan.driver_id = transport.driver_id
- plan.driver_name = transport.driver_name
- if transport.vehicle_id:
- snapshot = build_transport_resource_snapshot(
- db,
- tenant_no,
- transport.vehicle_id,
- selected_driver_id=transport.driver_id,
- )
- apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
- plan.transport_status = "arranged"
- plan.follow_status = "arranged"
- plan.freight_amount = freight_amount or 0
- plan.update_by = user.id
- # 安排运输时:关联且仍为「已审批」的合同自动开始执行
- from app.services.pl_contract_plan_qty import auto_start_execute_approved_contracts
- auto_start_execute_approved_contracts(
- db, tenant_no=tenant_no, plan=plan, user_id=user.id
- )
- needs_site_link = plan_requires_site_link(db, tenant_no=tenant_no, plan=plan)
- if is_rearrange:
- _rotate_driver_access_token(transport)
- if needs_site_link and plan_has_load_stop(db, tenant_no, plan.id):
- rotate_transport_site_token(transport)
- elif not needs_site_link:
- transport.site_access_token = None
- if plan.plan_type in ("inbound", "outbound"):
- void_io_orders_for_plan(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- user_id=user.id,
- )
- else:
- _ensure_driver_access_token(transport)
- site_records: list[TransportSiteLinkRead] = []
- inventory_records: list[TransportInventoryLinkRead] = []
- io_order_brief: Optional[TransportIoOrderBrief] = None
- if needs_site_link:
- use_multi_stop_site = plan_has_load_stop(db, tenant_no, plan.id)
- if use_multi_stop_site:
- site_token = ensure_transport_site_token(transport)
- site_url = build_transport_site_url(site_token)
- site_records = [
- TransportSiteLinkRead(
- plan_product_id="",
- product_name=plan.plan_no or transport.transport_no,
- site_link_id=transport.id,
- plan_no=plan.plan_no or "",
- business_type="按段现场",
- site_access_token=site_token,
- site_url=site_url,
- )
- ]
- elif plan.plan_type in ("inbound", "outbound"):
- try:
- if is_rearrange:
- # 调整运输:先作废旧出入库单(现场链接随之失效),再重建
- void_io_orders_for_plan(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- user_id=user.id,
- )
- io_result = create_io_order_for_transport(
- db,
- tenant_no=tenant_no,
- plan=plan,
- products=products,
- transport=transport,
- user_id=user.id,
- dept_id=user.dept_id or "",
- after_dibang=False,
- )
- if io_result:
- io_order_brief = TransportIoOrderBrief(
- kind=io_result.kind,
- order_id=io_result.order_id,
- order_no=io_result.order_no,
- site_access_token=io_result.site_access_token,
- site_url=io_result.site_url,
- status="in_progress",
- )
- # 兼容旧前端:合成单条 siteRecords 供复制现场链接
- site_records = [
- TransportSiteLinkRead(
- plan_product_id="",
- product_name=io_result.order_no,
- site_link_id=io_result.order_id,
- plan_no=plan.plan_no or "",
- business_type="入库" if io_result.kind == "inbound" else "出库",
- site_access_token=io_result.site_access_token,
- site_url=io_result.site_url,
- )
- ]
- except ValueError as exc:
- db.rollback()
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- use_multi_stop_dibang = plan_has_load_stop(db, tenant_no, plan.id)
- stop_dibang_options: list[StopDibangOption] = []
- if use_multi_stop_dibang and payload.stop_dibang_options:
- stop_dibang_options = [
- StopDibangOption(
- stop_type=item.stop_type, # type: ignore[arg-type]
- stop_id=item.stop_id,
- generate_dibang=item.generate_dibang,
- )
- for item in payload.stop_dibang_options
- ]
- should_sync_legacy_dibang = (
- not use_multi_stop_dibang
- and plan.plan_type in ("inbound", "outbound")
- and payload.generate_dibang_plan
- )
- should_sync_stop_dibang = use_multi_stop_dibang and any(
- item.generate_dibang for item in stop_dibang_options
- )
- if should_sync_stop_dibang or should_sync_legacy_dibang:
- try:
- if should_sync_stop_dibang:
- link_results = sync_dibang_for_plan_stops(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport=transport,
- stop_options=stop_dibang_options,
- user_id=user.id,
- dept_id=user.dept_id or "",
- )
- else:
- link_results = sync_dibang_for_transport(
- db,
- tenant_no=tenant_no,
- plan=plan,
- products=products,
- transport=transport,
- user_id=user.id,
- dept_id=user.dept_id or "",
- )
- inventory_records = [
- build_transport_inventory_link_read(
- db,
- plan_product_id=item.plan_product_id,
- product_name=item.product_name,
- db_purchase_plan_id=item.db_purchase_plan_id,
- db_inventory_id=item.db_inventory_id,
- batch_no=item.batch_no,
- plan_no=item.plan_no,
- )
- for item in link_results
- ]
- except ValueError as exc:
- db.rollback()
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- apply_dibang_prefill_to_order(
- db, tenant_no=tenant_no, plan=plan, transport=transport
- )
- db.commit()
- db.refresh(transport)
- return _build_arrange_result(
- transport,
- site_records,
- inventory_records,
- io_order_brief,
- contract_qty_reminder=contract_qty_reminder_payload,
- )
- # =============================================================================
- # 获取运输详情
- # =============================================================================
- @router.get("/{id}", response_model=TransportRead)
- def get_transport(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> TransportRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- transport = _get_transport(db, tenant_no, id, user)
- return _transport_to_read(db, transport)
- # =============================================================================
- # 生成二维码
- # =============================================================================
- class QrCodeResponse(BaseModel):
- transport_id: str = Field(alias="transportId")
- transport_no: str = Field(alias="transportNo")
- qr_code: str = Field(alias="qrCode")
- expire_date: Optional[date] = Field(default=None, alias="expireDate")
- model_config = {"from_attributes": True, "populate_by_name": True}
- @router.get("/{id}/qrcode", response_model=QrCodeResponse)
- def generate_qrcode(
- id: str,
- expire_days: int = Query(default=30, ge=1, le=365, description="有效期天数"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> QrCodeResponse:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- transport = _get_transport(db, tenant_no, id, user)
- from datetime import timedelta
- expire_date = date.today() + timedelta(days=expire_days)
- # 生成二维码内容(URL scheme)
- qr_content = f"/transport/scan/{transport.id}?code={transport.transport_no}"
- transport.qr_code = qr_content
- transport.qr_expire_date = expire_date
- db.commit()
- return QrCodeResponse(
- transport_id=transport.id,
- transport_no=transport.transport_no,
- qr_code=qr_content,
- expire_date=expire_date,
- )
- # =============================================================================
- # 司机扫码回传(无需登录)
- # =============================================================================
- @router.post("/scan/feedback")
- def transport_scan_feedback(
- payload: TransportFeedback,
- db: Session = Depends(get_db),
- ) -> dict:
- """司机扫码回传,无需登录"""
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.transport_no == payload.transport_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport:
- raise HTTPException(status_code=404, detail="运输单不存在")
- if payload.verify_code and payload.verify_code != transport.transport_no:
- raise HTTPException(status_code=400, detail="验证码错误")
- # 更新回传信息
- if payload.feedback_qty:
- transport.feedback_qty = payload.feedback_qty
- if payload.feedback_images:
- transport.feedback_images = json.dumps(payload.feedback_images)
- if payload.feedback_remark:
- transport.feedback_remark = payload.feedback_remark
- transport.feedback_time = datetime.now()
- db.commit()
- return {"success": True, "message": "回传成功"}
- # =============================================================================
- # 提交审批
- # =============================================================================
- @router.post("/{id}/submit-approval", response_model=TransportRead)
- def submit_approval(
- id: str,
- payload: ApprovalSubmit,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> TransportRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- transport = _get_transport(db, tenant_no, id, user)
- if transport.approval_status not in ("none", "rejected"):
- raise HTTPException(status_code=400, detail="当前状态不允许提交审批")
- # 创建审批记录
- for idx, approver_id in enumerate(payload.approver_ids):
- from app.models.models import SysUser
- approver = db.query(SysUser).filter(SysUser.id == approver_id).first()
- if not approver:
- raise HTTPException(status_code=400, detail=f"审批人不存在: {approver_id}")
- approval = PlTransportApproval(
- id=generate_uuid(),
- tenant_no=tenant_no,
- transport_id=id,
- approver_id=approver_id,
- approver_name=approver.nick_name,
- approval_order=idx + 1,
- status="pending",
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(approval)
- transport.approval_status = "pending"
- db.commit()
- db.refresh(transport)
- return transport
- # =============================================================================
- # 审批
- # =============================================================================
- @router.post("/approval/{id}", response_model=ApprovalRecordRead)
- def process_approval(
- id: str,
- payload: ApprovalRequest,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> ApprovalRecordRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- approval = (
- db.query(PlTransportApproval)
- .filter(
- PlTransportApproval.id == id,
- PlTransportApproval.tenant_no == tenant_no,
- PlTransportApproval.del_flag == 0,
- )
- .first()
- )
- if not approval:
- raise HTTPException(status_code=404, detail="审批记录不存在")
- if approval.status != "pending":
- raise HTTPException(status_code=400, detail="该审批已处理")
- # 更新审批状态
- from datetime import datetime
- approval.status = "approved" if payload.action == "approve" else "rejected"
- approval.comment = payload.comment
- approval.approval_time = datetime.now()
- # 更新运输审批状态
- transport = db.query(PlTransport).filter(PlTransport.id == approval.transport_id).first()
- if transport:
- if payload.action == "approve":
- # 检查是否所有审批人都通过了
- pending = (
- db.query(PlTransportApproval)
- .filter(
- PlTransportApproval.transport_id == approval.transport_id,
- PlTransportApproval.status == "pending",
- PlTransportApproval.del_flag == 0,
- )
- .count()
- )
- if pending == 0:
- transport.approval_status = "approved"
- transport.transport_status = "pending"
- else:
- transport.approval_status = "rejected"
- transport.transport_status = "draft"
- db.commit()
- db.refresh(approval)
- return approval
- # =============================================================================
- # 获取运输的审批记录
- # =============================================================================
- @router.get("/{id}/approvals", response_model=list[ApprovalRecordRead])
- def get_transport_approvals(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> list[ApprovalRecordRead]:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- approvals = (
- db.query(PlTransportApproval)
- .filter(
- PlTransportApproval.transport_id == id,
- PlTransportApproval.tenant_no == tenant_no,
- PlTransportApproval.del_flag == 0,
- )
- .order_by(PlTransportApproval.approval_order)
- .all()
- )
- return approvals
- # =============================================================================
- # 辅助函数
- # =============================================================================
- def _get_transport(db: Session, tenant_no: str, transport_id: str, user: DbUserContext) -> PlTransport:
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.id == transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport:
- raise HTTPException(status_code=404, detail="运输不存在")
- assert_pl_row_in_scope(db, transport, PlTransport, user, tenant_no)
- return transport
|