| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412 |
- """运输计划司机免登录公共 API"""
- from __future__ import annotations
- import json
- import os
- from datetime import date, datetime
- from typing import Optional
- from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
- from sqlalchemy.orm import Session
- from app.core.storage import storage
- from app.core.attachment_biz import resolve_upload_folder
- from app.core.config import settings
- from app.core.database import get_db
- from app.core.response import error_response, success_response
- from app.crud import oss as oss_svc
- from app.models.crm_models import CrmProductBrand
- from app.models.pl_models import (
- PlPlan,
- PlPlanProduct,
- PlPlanTransport,
- PlTransport,
- PlTransportProductBrandQty,
- PlTransportProductFeedback,
- )
- from app.schemas.pl.io_order import BrandOption, BrandQtyItem
- from app.schemas.pl.resource_snapshot import parse_resource_snapshot
- from app.schemas.pl.transport import (
- PublicTransportFeedback,
- PublicTransportPageRead,
- PublicTransportPersonnelRead,
- PublicTransportPlanProductRead,
- )
- from app.services.pl_feedback_brand_qty import persist_transport_product_feedback
- from app.services.pl_product_display import (
- load_crm_product_alias_maps,
- resolve_product_display_names,
- )
- from app.utils.excel_export import load_dict_label_maps
- router = APIRouter(prefix="/public/pl/transport", tags=["运输计划-司机公共"])
- CRM_PRODUCT_UNIT = "crm_product_unit"
- def _resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str:
- code = (unit_code or "").strip()
- if not code:
- return "吨"
- return unit_map.get(code, code)
- def _get_transport_by_token(db: Session, token: str) -> PlTransport:
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.driver_access_token == token,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport:
- raise HTTPException(status_code=404, detail="链接无效或已失效")
- return transport
- def _parse_feedback_images(raw: Optional[str]) -> list[str]:
- if not raw:
- return []
- try:
- parsed = json.loads(raw)
- if isinstance(parsed, list):
- return [str(item) for item in parsed if item]
- except (TypeError, json.JSONDecodeError):
- pass
- return [part.strip() for part in raw.split(",") if part.strip()]
- def _format_plan_date(value) -> Optional[str]:
- if value is None:
- return None
- if isinstance(value, datetime):
- return value.strftime("%Y-%m-%d")
- if isinstance(value, date):
- return value.isoformat()
- return str(value)
- def _resolve_driver_link_flags(
- plan: Optional[PlPlan], transport: PlTransport
- ) -> tuple[bool, bool]:
- """返回 (作废, 归档)。"""
- voided = (
- (plan is not None and plan.follow_status == "cancelled")
- or transport.transport_status == "cancelled"
- )
- archived = bool(plan is not None and plan.archive_status == "archived")
- return voided, archived
- def _resolve_other_personnel(transport: PlTransport, plan: Optional[PlPlan]) -> list[PublicTransportPersonnelRead]:
- raw = transport.resource_snapshot or (plan.resource_snapshot if plan else None)
- snapshot = parse_resource_snapshot(raw)
- if not snapshot or not snapshot.personnel_list:
- return []
- selected_driver_id = (snapshot.selected_driver_id or transport.driver_id or "").strip()
- driver_name = (transport.driver_name or "").strip()
- rows: list[PublicTransportPersonnelRead] = []
- for person in snapshot.personnel_list:
- personnel_id = (person.personnel_id or "").strip()
- personnel_name = (person.personnel_name or "").strip()
- is_primary_driver = person.role == "driver" and (
- person.is_default == 1
- or (selected_driver_id and personnel_id == selected_driver_id)
- or (driver_name and personnel_name == driver_name)
- )
- if is_primary_driver:
- continue
- rows.append(
- PublicTransportPersonnelRead(
- personnel_name=personnel_name or "—",
- phone_no=person.phone_no,
- role=person.role,
- )
- )
- return rows
- def _build_public_page(db: Session, transport: PlTransport) -> PublicTransportPageRead:
- link = (
- db.query(PlPlanTransport)
- .filter(
- PlPlanTransport.transport_id == transport.id,
- PlPlanTransport.del_flag == 0,
- )
- .order_by(PlPlanTransport.create_time.desc())
- .first()
- )
- plan = None
- products: list[PlPlanProduct] = []
- if link:
- plan = (
- db.query(PlPlan)
- .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0)
- .first()
- )
- if plan:
- products = (
- db.query(PlPlanProduct)
- .filter(PlPlanProduct.plan_id == plan.id, PlPlanProduct.del_flag == 0)
- .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
- .all()
- )
- crm_product_ids = {(p.product_id or "").strip() for p in products if (p.product_id or "").strip()}
- brand_options_by_product: dict[str, list[BrandOption]] = {}
- if crm_product_ids:
- brand_rows = (
- db.query(CrmProductBrand)
- .filter(
- CrmProductBrand.product_id.in_(list(crm_product_ids)),
- CrmProductBrand.tenant_no == transport.tenant_no,
- CrmProductBrand.status == "on",
- CrmProductBrand.del_flag == 0,
- )
- .order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc())
- .all()
- )
- for row in brand_rows:
- brand_options_by_product.setdefault(row.product_id, []).append(
- BrandOption(brand_id=row.id, brand_name=row.brand_name or "")
- )
- feedback_by_plan_product: dict[str, PlTransportProductFeedback] = {}
- brand_qtys_by_plan_product: dict[str, list[BrandQtyItem]] = {}
- feedback_rows = (
- db.query(PlTransportProductFeedback)
- .filter(
- PlTransportProductFeedback.transport_id == transport.id,
- PlTransportProductFeedback.tenant_no == transport.tenant_no,
- PlTransportProductFeedback.del_flag == 0,
- )
- .order_by(PlTransportProductFeedback.order_num.asc())
- .all()
- )
- for fb in feedback_rows:
- feedback_by_plan_product[fb.plan_product_id] = fb
- if feedback_rows:
- feedback_ids = [fb.id for fb in feedback_rows]
- brand_qty_rows = (
- db.query(PlTransportProductBrandQty)
- .filter(
- PlTransportProductBrandQty.transport_id == transport.id,
- PlTransportProductBrandQty.tenant_no == transport.tenant_no,
- PlTransportProductBrandQty.del_flag == 0,
- PlTransportProductBrandQty.feedback_id.in_(feedback_ids),
- )
- .order_by(PlTransportProductBrandQty.order_num.asc())
- .all()
- )
- for row in brand_qty_rows:
- brand_qtys_by_plan_product.setdefault(row.plan_product_id, []).append(
- BrandQtyItem(
- brand_id=row.brand_id or "",
- brand_name=row.brand_name,
- qty=row.qty or 0,
- )
- )
- standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
- db, tenant_no=transport.tenant_no, product_ids=crm_product_ids
- )
- product_reads = []
- unit_map = load_dict_label_maps(
- db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
- ).get(CRM_PRODUCT_UNIT, {})
- for prod in products:
- product_id = (prod.product_id or "").strip()
- options = brand_options_by_product.get(product_id) or None
- fb = feedback_by_plan_product.get(prod.id)
- brand_qtys = brand_qtys_by_plan_product.get(prod.id) or None
- display_name, alias_name = resolve_product_display_names(
- saved_product_name=prod.product_name,
- product_id=product_id,
- standard_by_id=standard_by_id,
- alias_options_by_id=alias_options_by_id,
- )
- product_reads.append(
- PublicTransportPlanProductRead(
- plan_product_id=prod.id,
- product_name=display_name,
- alias_name=alias_name,
- model=prod.model,
- package_spec=prod.package_spec,
- unit=_resolve_unit_label(unit_map, prod.unit),
- quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
- remark=(prod.remark or "").strip() or None,
- brand_mode=prod.brand_mode,
- brand_id=prod.brand_id,
- brand_name=prod.brand_name,
- brand_options=options,
- actual_qty=fb.actual_qty if fb else None,
- brand_qtys=brand_qtys,
- )
- )
- other_personnel = _resolve_other_personnel(transport, plan)
- plan_voided, plan_archived = _resolve_driver_link_flags(plan, transport)
- return PublicTransportPageRead(
- plan_no=plan.plan_no if plan else transport.transport_no,
- plan_date=_format_plan_date(plan.plan_date if plan else None),
- plan_type=plan.plan_type if plan else "",
- pickup_address=plan.pickup_address if plan else transport.start_point,
- pickup_lng=plan.pickup_lng if plan else None,
- pickup_lat=plan.pickup_lat if plan else None,
- pickup_time=plan.pickup_time if plan else None,
- pickup_contact=plan.pickup_contact if plan else None,
- pickup_notes=plan.pickup_notes if plan else None,
- destination_name=plan.destination_name if plan else None,
- destination_address=(
- (plan.destination_address or plan.destination_name) if plan else transport.end_point
- ),
- destination_lng=plan.destination_lng if plan else None,
- destination_lat=plan.destination_lat if plan else None,
- delivery_time=plan.delivery_time if plan else None,
- delivery_contact=plan.delivery_contact if plan else None,
- destination_notes=plan.destination_notes if plan else None,
- license_plate=transport.license_plate,
- driver_name=transport.driver_name,
- driver_phone=transport.driver_phone,
- other_personnel=other_personnel,
- products=product_reads,
- feedback_remark=transport.feedback_remark,
- feedback_images=_parse_feedback_images(transport.feedback_images),
- feedback_qty=transport.feedback_qty,
- feedback_time=transport.feedback_time,
- plan_voided=plan_voided,
- plan_archived=plan_archived,
- )
- @router.get("/{token}")
- def get_public_transport(token: str, db: Session = Depends(get_db)):
- transport = _get_transport_by_token(db, token)
- page = _build_public_page(db, transport)
- return success_response(page.model_dump(by_alias=True))
- @router.post("/{token}/feedback")
- def save_public_feedback(
- token: str,
- payload: PublicTransportFeedback,
- db: Session = Depends(get_db),
- ):
- transport = _get_transport_by_token(db, token)
- page = _build_public_page(db, transport)
- if page.plan_voided:
- return error_response(400, "运输计划已作废,无法提交回执")
- if page.plan_archived:
- return error_response(400, "运输计划已归档,无法提交回执")
- if payload.feedback_images is not None:
- transport.feedback_images = json.dumps(payload.feedback_images)
- if payload.feedback_remark is not None:
- transport.feedback_remark = payload.feedback_remark
- if payload.products:
- link = (
- db.query(PlPlanTransport)
- .filter(
- PlPlanTransport.transport_id == transport.id,
- PlPlanTransport.del_flag == 0,
- )
- .order_by(PlPlanTransport.create_time.desc())
- .first()
- )
- if not link or not link.plan_id:
- return error_response(400, "未找到关联计划,无法保存产品回传")
- products_payload = [
- {
- "planProductId": item.plan_product_id,
- "actualQty": item.actual_qty,
- "brandQtys": [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])],
- }
- for item in payload.products
- ]
- try:
- persist_transport_product_feedback(
- db,
- tenant_no=transport.tenant_no,
- transport=transport,
- plan_id=link.plan_id,
- products=products_payload,
- user_id=None,
- enforce_brand=True,
- )
- except ValueError as exc:
- return error_response(400, str(exc))
- elif payload.feedback_qty is not None:
- transport.feedback_qty = payload.feedback_qty
- transport.feedback_time = datetime.now()
- db.commit()
- return success_response({"success": True, "message": "保存成功"})
- def _format_file_size(size_bytes: int) -> str:
- if size_bytes < 1024:
- return f"{size_bytes} B"
- if size_bytes < 1024 * 1024:
- return f"{size_bytes / 1024:.1f} KB"
- return f"{size_bytes / (1024 * 1024):.1f} MB"
- @router.post("/{token}/attachments")
- def upload_public_attachment(
- token: str,
- file: UploadFile = File(...),
- folder: Optional[str] = Form(""),
- db: Session = Depends(get_db),
- ):
- transport = _get_transport_by_token(db, token)
- page = _build_public_page(db, transport)
- if page.plan_voided:
- return error_response(400, "运输计划已作废,无法上传附件")
- if page.plan_archived:
- return error_response(400, "运输计划已归档,无法上传附件")
- if not file or not file.filename:
- return error_response(400, "请选择文件")
- try:
- effective_folder = resolve_upload_folder(folder=folder or "", biz_type="pl_transport_feedback")
- file_content = file.file.read()
- full_url = storage.upload_file(file_content, file.filename, effective_folder)
- base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
- if base_url and full_url.startswith(base_url + "/"):
- file_url = full_url[len(base_url) + 1 :]
- else:
- file_url = full_url
- record = oss_svc.create_oss(
- db,
- tenant_no=transport.tenant_no,
- url=file_url,
- original_name=file.filename,
- file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
- file_size=len(file_content),
- file_type=file.content_type or "",
- biz_type="pl_transport_feedback",
- user_id=None,
- ext1=effective_folder or None,
- )
- db.commit()
- return success_response(
- {
- "id": record.id,
- "fileName": record.original_name,
- "fileUrl": record.url,
- "fileSize": record.file_size,
- "fileSizeDisplay": _format_file_size(record.file_size or 0),
- "fileType": record.file_type,
- }
- )
- except Exception as exc:
- db.rollback()
- return error_response(500, f"上传失败: {str(exc)}")
|