| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352 |
- """运输计划管理 - 运输 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.services.pl_plan_stop_service import assert_no_pending_stop_products
- 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,
- 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,
- )
- 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 _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],
- escort_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.escort_id = escort_info["id"] if escort_info else None
- transport.escort_name = escort_info["name"] if escort_info else None
- transport.escort_phone = escort_info["phone"] if escort_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,
- escort_id=payload.escort_id,
- escort_name=payload.escort_name,
- escort_phone=payload.escort_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
- plan.escort_id = transport.escort_id
- plan.escort_name = transport.escort_name
- plan.escort_phone = transport.escort_phone
- if transport.vehicle_id:
- snapshot = build_transport_resource_snapshot(
- db,
- tenant_no,
- transport.vehicle_id,
- selected_driver_id=transport.driver_id,
- selected_escort_id=transport.escort_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)
- assert_no_pending_stop_products(db, tenant_no, plan_id)
- 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)
- from app.services.pl_plan_execution_lock import (
- execution_lock_message,
- plan_execution_lock_reason,
- )
- lock_reason = plan_execution_lock_reason(db, tenant_no, plan.id)
- if lock_reason:
- raise HTTPException(
- status_code=400, detail=execution_lock_message(lock_reason, "arrange")
- )
- 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="司机不存在")
- # 获取押运员信息(可选)
- escort_info = None
- if payload.escort_id:
- escort_info = _resolve_driver_info(db, tenant_no, payload.escort_id)
- if not escort_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)
- # 地磅关联改为按段 upsert,不再在调整前整批 revoke 再建
- 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,
- escort_info=escort_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,
- escort_info=escort_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
- plan.escort_id = transport.escort_id
- plan.escort_name = transport.escort_name
- plan.escort_phone = transport.escort_phone
- if transport.vehicle_id:
- snapshot = build_transport_resource_snapshot(
- db,
- tenant_no,
- transport.vehicle_id,
- selected_driver_id=transport.driver_id,
- selected_escort_id=transport.escort_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:
- _ensure_driver_access_token(transport)
- if needs_site_link and plan_has_load_stop(db, tenant_no, plan.id):
- ensure_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
- )
- # 有 stop 选项即同步:全 false 也要进 upsert,才能取消勾选 / 已过磅阻断
- should_sync_stop_dibang = use_multi_stop_dibang and bool(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 "",
- )
- inv_ids = [
- (item.db_inventory_id or "").strip()
- for item in link_results
- if (item.db_inventory_id or "").strip()
- ]
- inv_by_id: dict = {}
- if inv_ids:
- from app.models.db_models import DbInboundOutbound
- inv_rows = (
- db.query(DbInboundOutbound)
- .filter(
- DbInboundOutbound.id.in_(inv_ids),
- DbInboundOutbound.tenant_no == tenant_no,
- DbInboundOutbound.del_flag == 0,
- )
- .all()
- )
- inv_by_id = {row.id: row for row in inv_rows}
- 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,
- business_type=(
- inv_by_id[item.db_inventory_id].business_type
- if item.db_inventory_id in inv_by_id
- else None
- ),
- partner_company=(
- inv_by_id[item.db_inventory_id].partner_company
- if item.db_inventory_id in inv_by_id
- else None
- ),
- )
- 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
|