| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- """运输计划 · 现场按段免登录公共 API(一车多卸)"""
- from __future__ import annotations
- import os
- from datetime import date, datetime
- from decimal import Decimal
- from typing import Optional
- from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
- from sqlalchemy import and_, func, or_
- from sqlalchemy.orm import Session
- from app.api.pl.public_transport import (
- CRM_PRODUCT_UNIT,
- _history_status_label,
- _normalize_main_plate,
- _parse_history_date,
- _resolve_driver_link_flags,
- _resolve_unit_label,
- )
- 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.core.storage import storage
- from app.crud import oss as oss_svc
- from app.models.pl_models import (
- PlPlan,
- PlPlanDelivery,
- PlPlanLoad,
- PlPlanProduct,
- PlPlanTransport,
- PlTransport,
- )
- from app.schemas.pl.transport import (
- PublicTransportHistoryPageRead,
- PublicTransportHistoryProductRead,
- PublicTransportHistoryRecordRead,
- PublicTransportHistoryStopProductRead,
- PublicTransportHistoryStopRead,
- PublicTransportMultiStopSitePageRead,
- PublicTransportSiteStopFeedback,
- )
- from app.services.pl_product_display import (
- load_crm_product_alias_maps,
- resolve_product_display_names,
- )
- from app.services.pl_transport_stop_feedback_service import (
- build_public_stop_reads_for_segments,
- load_site_stop_segments,
- save_site_stop_feedback,
- )
- from app.utils.excel_export import load_dict_label_maps
- router = APIRouter(prefix="/public/pl/transport-site", tags=["运输计划-现场按段公共"])
- def _get_transport_by_site_token(db: Session, token: str) -> PlTransport:
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.site_access_token == token,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport:
- raise HTTPException(status_code=404, detail="链接无效或已失效")
- return transport
- 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_plan_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 _build_public_page(db: Session, transport: PlTransport) -> PublicTransportMultiStopSitePageRead:
- link = (
- db.query(PlPlanTransport)
- .filter(
- PlPlanTransport.transport_id == transport.id,
- PlPlanTransport.del_flag == 0,
- )
- .order_by(PlPlanTransport.create_time.desc())
- .first()
- )
- plan = None
- if link:
- plan = (
- db.query(PlPlan)
- .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0)
- .first()
- )
- plan_voided, plan_archived = _resolve_plan_flags(plan, transport)
- stop_reads: list[dict] = []
- if plan:
- segments = load_site_stop_segments(
- db,
- tenant_no=transport.tenant_no,
- plan_id=plan.id,
- transport_id=transport.id,
- )
- stop_reads = build_public_stop_reads_for_segments(
- db,
- tenant_no=transport.tenant_no,
- plan_id=plan.id,
- segments=segments,
- )
- return PublicTransportMultiStopSitePageRead(
- plan_no=plan.plan_no if plan else transport.transport_no,
- plan_date=_format_plan_date(plan.plan_date if plan else None),
- license_plate=transport.license_plate,
- vehicle_no=transport.license_plate,
- plan_voided=plan_voided,
- plan_archived=plan_archived,
- stops=stop_reads,
- )
- def _build_site_url(token: str) -> str:
- origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
- return f"{origin}/#/public/pl/transport-site/{token}"
- def _build_site_history_stop_reads(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- transport: PlTransport,
- ) -> list[PublicTransportHistoryStopRead]:
- plan_products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == plan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- )
- .all()
- )
- plan_products_by_id = {row.id: row for row in plan_products}
- crm_product_ids = {
- (row.product_id or "").strip() for row in plan_products if (row.product_id or "").strip()
- }
- standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
- db, tenant_no=tenant_no, product_ids=crm_product_ids
- )
- unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
- CRM_PRODUCT_UNIT, {}
- )
- load_row = (
- db.query(PlPlanLoad)
- .filter(
- PlPlanLoad.plan_id == plan.id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- )
- .first()
- )
- delivery_rows = (
- db.query(PlPlanDelivery)
- .filter(
- PlPlanDelivery.plan_id == plan.id,
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- )
- .all()
- )
- delivery_time_by_id = {row.id: row.delivery_time for row in delivery_rows}
- load_time = load_row.load_time if load_row else None
- segments = load_site_stop_segments(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- transport_id=transport.id,
- )
- if not segments:
- return []
- reads: list[PublicTransportHistoryStopRead] = []
- for segment in segments:
- stop_time = load_time if segment.stop_type == "load" else delivery_time_by_id.get(segment.stop_id)
- stop_products: list[PublicTransportHistoryStopProductRead] = []
- for row in segment.products:
- plan_prod = plan_products_by_id.get(row.plan_product_id)
- product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip()
- display_name, _alias_name = resolve_product_display_names(
- saved_product_name=row.product_name,
- product_id=product_id,
- standard_by_id=standard_by_id,
- alias_options_by_id=alias_options_by_id,
- )
- stop_products.append(
- PublicTransportHistoryStopProductRead(
- product_name=display_name or row.product_name or "—",
- model=row.model,
- package_spec=row.package_spec,
- quantity=row.plan_qty,
- unit=_resolve_unit_label(unit_map, row.unit),
- )
- )
- reads.append(
- PublicTransportHistoryStopRead(
- stop_type=segment.stop_type,
- seq=segment.seq,
- label=segment.label,
- customer_name=segment.customer_name,
- address=segment.address,
- stop_time=stop_time,
- products=stop_products,
- )
- )
- return reads
- @router.get("/{token}/history")
- def get_public_transport_site_history(
- token: str,
- date: str = Query(..., description="筛选日 YYYY-MM-DD"),
- q: Optional[str] = Query(None, description="车牌号/产品名称模糊"),
- db: Session = Depends(get_db),
- ):
- transport = _get_transport_by_site_token(db, token)
- day = _parse_history_date(date)
- if day is None:
- return error_response(400, "日期格式无效,请使用 YYYY-MM-DD")
- kw = (q or "").strip()
- query = (
- db.query(PlPlan, PlTransport)
- .join(
- PlPlanTransport,
- and_(
- PlPlanTransport.plan_id == PlPlan.id,
- PlPlanTransport.del_flag == 0,
- ),
- )
- .join(
- PlTransport,
- and_(
- PlTransport.id == PlPlanTransport.transport_id,
- PlTransport.del_flag == 0,
- ),
- )
- .filter(
- PlPlan.tenant_no == transport.tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.transport_status == "arranged",
- PlTransport.tenant_no == transport.tenant_no,
- PlTransport.site_access_token.isnot(None),
- PlTransport.site_access_token != "",
- or_(
- PlPlan.plan_date == day,
- and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
- and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day),
- ),
- )
- )
- if kw:
- like = f"%{kw}%"
- product_match = (
- db.query(PlPlanProduct.id)
- .filter(
- PlPlanProduct.plan_id == PlPlan.id,
- PlPlanProduct.del_flag == 0,
- PlPlanProduct.product_name.like(like),
- )
- .exists()
- )
- query = query.filter(or_(PlTransport.license_plate.like(like), product_match))
- rows = query.order_by(
- PlPlan.pickup_time.desc(),
- PlPlan.plan_date.desc(),
- PlPlan.create_time.desc(),
- ).all()
- seen_plan: set[str] = set()
- plan_rows: list[tuple[PlPlan, PlTransport, list[PublicTransportHistoryStopRead]]] = []
- for plan, tr in rows:
- if plan.id in seen_plan:
- continue
- site_tok = (tr.site_access_token or "").strip()
- if not site_tok:
- continue
- site_stops = _build_site_history_stop_reads(
- db, tenant_no=transport.tenant_no, plan=plan, transport=tr
- )
- if not site_stops:
- continue
- seen_plan.add(plan.id)
- plan_rows.append((plan, tr, site_stops))
- products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {}
- if plan_rows:
- plan_ids = [plan.id for plan, _, _ in plan_rows]
- product_rows = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id.in_(plan_ids),
- 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 product_rows if (p.product_id or "").strip()
- }
- standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
- db, tenant_no=transport.tenant_no, product_ids=crm_product_ids
- )
- unit_map = load_dict_label_maps(
- db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
- ).get(CRM_PRODUCT_UNIT, {})
- for prod in product_rows:
- product_id = (prod.product_id or "").strip()
- 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,
- )
- products_by_plan.setdefault(prod.plan_id, []).append(
- PublicTransportHistoryProductRead(
- product_name=display_name or prod.product_name or "—",
- model=prod.model,
- package_spec=prod.package_spec,
- quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
- unit=_resolve_unit_label(unit_map, prod.unit),
- )
- )
- records: list[PublicTransportHistoryRecordRead] = []
- for plan, tr, site_stops in plan_rows:
- voided, archived = _resolve_driver_link_flags(plan, tr)
- site_tok = (tr.site_access_token or "").strip()
- records.append(
- PublicTransportHistoryRecordRead(
- plan_id=plan.id,
- plan_no=plan.plan_no or "",
- transport_id=tr.id,
- site_access_token=site_tok or None,
- site_url=_build_site_url(site_tok) if site_tok else None,
- license_plate=tr.license_plate,
- pickup_time=plan.pickup_time,
- delivery_time=plan.delivery_time,
- pickup_address=plan.pickup_address,
- destination_address=plan.destination_address or plan.destination_name,
- products=products_by_plan.get(plan.id) or [],
- stops=site_stops,
- plan_voided=voided,
- plan_archived=archived,
- status_label=_history_status_label(voided, archived),
- )
- )
- page = PublicTransportHistoryPageRead(
- license_plate=_normalize_main_plate(transport.license_plate) or None,
- date=day.isoformat(),
- keyword=kw or None,
- records=records,
- )
- return success_response(page.model_dump(by_alias=True))
- @router.get("/{token}")
- def get_public_transport_site(token: str, db: Session = Depends(get_db)):
- transport = _get_transport_by_site_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_site_stop_feedback(
- token: str,
- payload: PublicTransportSiteStopFeedback,
- db: Session = Depends(get_db),
- ):
- transport = _get_transport_by_site_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, "运输计划已归档,无法提交回执")
- 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, "未找到关联计划,无法保存段回传")
- stop_type = (payload.stop_type or "").strip()
- stop_id = (payload.stop_id or "").strip()
- if stop_type not in ("load", "delivery") or not stop_id:
- return error_response(400, "停靠段参数无效")
- segments = load_site_stop_segments(
- db,
- tenant_no=transport.tenant_no,
- plan_id=link.plan_id,
- transport_id=transport.id,
- )
- segment_map = {(item.stop_type, item.stop_id): item for item in segments}
- segment = segment_map.get((stop_type, stop_id))
- if not segment:
- return error_response(400, "该段不在现场回传范围内")
- try:
- save_site_stop_feedback(
- db,
- tenant_no=transport.tenant_no,
- transport_id=transport.id,
- plan_id=link.plan_id,
- stop_type=stop_type,
- stop_id=stop_id,
- business_type=segment.business_type or "入库",
- quantity=payload.quantity,
- attachment_urls=payload.attachment_urls,
- remark=payload.remark,
- )
- except ValueError as exc:
- return error_response(400, str(exc))
- 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_site_attachment(
- token: str,
- file: UploadFile = File(...),
- folder: Optional[str] = Form(""),
- db: Session = Depends(get_db),
- ):
- transport = _get_transport_by_site_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_site_stop_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_site_stop_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)}")
|