| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515 |
- """运输计划 · 入库/出库单现场免登录公共 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_, or_
- from sqlalchemy.orm import Session
- 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 (
- PlInboundOrder,
- PlInboundOrderItem,
- PlOutboundOrder,
- PlOutboundOrderItem,
- PlPlan,
- PlPlanProduct,
- )
- from app.schemas.pl.io_order import (
- PublicIoOrderFeedback,
- PublicIoOrderHistoryPageRead,
- PublicIoOrderHistoryProductRead,
- PublicIoOrderHistoryRecordRead,
- )
- from app.services.pl_dibang_bridge_service import plan_has_load_stop
- from app.services.pl_io_order_service import (
- apply_io_order_stop_site_feedbacks,
- apply_site_feedback,
- build_io_order_site_url,
- order_header_to_dict,
- parse_feedback_images,
- )
- from app.services.pl_transport_stop_feedback_service import (
- build_public_stop_reads_for_segments,
- load_io_order_site_stop_segments,
- )
- 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
- inbound_public_router = APIRouter(prefix="/public/pl/inbound", tags=["运输计划-入库单公共"])
- outbound_public_router = APIRouter(prefix="/public/pl/outbound", 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 _models(kind: str):
- if kind == "inbound":
- return PlInboundOrder, PlInboundOrderItem
- return PlOutboundOrder, PlOutboundOrderItem
- def _get_by_token(db: Session, kind: str, token: str):
- header_m, item_m = _models(kind)
- header = (
- db.query(header_m)
- .filter(header_m.site_access_token == token, header_m.del_flag == 0)
- .first()
- )
- if not header:
- raise HTTPException(status_code=404, detail="链接无效或已失效")
- items = (
- db.query(item_m)
- .filter(item_m.order_id == header.id, item_m.del_flag == 0)
- .order_by(item_m.order_num.asc())
- .all()
- )
- return header, items
- def _resolve_io_order_link_flags(plan: Optional[PlPlan], header) -> tuple[bool, bool]:
- """返回 (作废, 归档)。现场链接实际走入库/出库单公共页。"""
- voided = header.status == "voided" or bool(plan and plan.follow_status == "cancelled")
- archived = bool(plan and plan.archive_status == "archived")
- return voided, archived
- def _parse_history_date(raw: Optional[str]) -> Optional[date]:
- text = (raw or "").strip()
- if not text:
- return None
- try:
- return date.fromisoformat(text)
- except ValueError:
- return None
- def _history_status_label(voided: bool, archived: bool, status: Optional[str]) -> str:
- if voided or status == "voided":
- return "已作废"
- if archived:
- return "已归档"
- if status == "completed":
- return "已完成"
- return "进行中"
- def _query_kind_history(
- db: Session,
- *,
- kind: str,
- tenant_no: str,
- day: date,
- keyword: str,
- ) -> list[tuple]:
- """返回 [(create_time, PublicIoOrderHistoryRecordRead), ...]。"""
- header_m, item_m = _models(kind)
- query = (
- db.query(header_m, PlPlan)
- .outerjoin(
- PlPlan,
- and_(PlPlan.id == header_m.plan_id, PlPlan.del_flag == 0),
- )
- .filter(
- header_m.tenant_no == tenant_no,
- header_m.del_flag == 0,
- header_m.plan_date == day,
- )
- )
- kw = (keyword or "").strip()
- if kw:
- like = f"%{kw}%"
- product_match = (
- db.query(item_m.id)
- .filter(
- item_m.order_id == header_m.id,
- item_m.del_flag == 0,
- item_m.product_name.like(like),
- )
- .exists()
- )
- query = query.filter(
- or_(
- header_m.vehicle_no.like(like),
- product_match,
- )
- )
- rows = query.order_by(header_m.create_time.desc()).all()
- out: list[tuple] = []
- for header, plan in rows:
- voided, archived = _resolve_io_order_link_flags(plan, header)
- tok = (header.site_access_token or "").strip()
- out.append(
- (
- header.create_time,
- PublicIoOrderHistoryRecordRead(
- kind=kind,
- order_id=header.id,
- order_no=header.order_no or "",
- plan_id=header.plan_id,
- plan_no=header.plan_no or (plan.plan_no if plan else None),
- plan_date=header.plan_date,
- partner_company=header.partner_company,
- supplier_name=plan.supplier_name if plan else None,
- customer_name=plan.customer_name if plan else None,
- vehicle_no=header.vehicle_no,
- pickup_time=plan.pickup_time if plan else None,
- delivery_time=plan.delivery_time if plan else None,
- pickup_address=plan.pickup_address if plan else None,
- destination_address=(
- (plan.destination_address or plan.destination_name) if plan else None
- ),
- site_access_token=tok or None,
- site_url=build_io_order_site_url(kind, tok) if tok else None,
- status=header.status,
- order_voided=voided,
- plan_archived=archived,
- status_label=_history_status_label(voided, archived, header.status),
- ),
- )
- )
- return out
- def _build_history_page(
- db: Session,
- *,
- entry_kind: str,
- token: str,
- date_raw: str,
- keyword: Optional[str],
- page: int = 1,
- page_size: int = 20,
- ) -> PublicIoOrderHistoryPageRead:
- header, _items = _get_by_token(db, entry_kind, token)
- day = _parse_history_date(date_raw)
- if day is None:
- raise HTTPException(status_code=400, detail="日期格式无效,请使用 YYYY-MM-DD")
- kw = (keyword or "").strip()
- merged = _query_kind_history(
- db, kind="inbound", tenant_no=header.tenant_no, day=day, keyword=kw
- ) + _query_kind_history(
- db, kind="outbound", tenant_no=header.tenant_no, day=day, keyword=kw
- )
- merged.sort(key=lambda x: x[0] or datetime.min, reverse=True)
- total = len(merged)
- page = max(1, int(page or 1))
- page_size = min(max(1, int(page_size or 20)), 500)
- start = (page - 1) * page_size
- page_rows = merged[start : start + page_size]
- records = [r for _, r in page_rows]
- _attach_history_products(db, tenant_no=header.tenant_no, records=records)
- return PublicIoOrderHistoryPageRead(
- date=day.isoformat(),
- keyword=kw or None,
- page=page,
- page_size=page_size,
- total=total,
- records=records,
- )
- def _attach_history_products(
- db: Session,
- *,
- tenant_no: str,
- records: list[PublicIoOrderHistoryRecordRead],
- ) -> None:
- if not records:
- return
- inbound_ids = [r.order_id for r in records if r.kind == "inbound"]
- outbound_ids = [r.order_id for r in records if r.kind == "outbound"]
- items_by_order: dict[str, list] = {}
- if inbound_ids:
- for item in (
- db.query(PlInboundOrderItem)
- .filter(
- PlInboundOrderItem.order_id.in_(inbound_ids),
- PlInboundOrderItem.del_flag == 0,
- )
- .order_by(PlInboundOrderItem.order_num.asc(), PlInboundOrderItem.create_time.asc())
- .all()
- ):
- items_by_order.setdefault(item.order_id, []).append(item)
- if outbound_ids:
- for item in (
- db.query(PlOutboundOrderItem)
- .filter(
- PlOutboundOrderItem.order_id.in_(outbound_ids),
- PlOutboundOrderItem.del_flag == 0,
- )
- .order_by(PlOutboundOrderItem.order_num.asc(), PlOutboundOrderItem.create_time.asc())
- .all()
- ):
- items_by_order.setdefault(item.order_id, []).append(item)
- all_items = [it for items in items_by_order.values() for it in items]
- plan_product_ids = {
- (it.plan_product_id or "").strip() for it in all_items if (it.plan_product_id or "").strip()
- }
- plan_product_by_id: dict[str, PlPlanProduct] = {}
- if plan_product_ids:
- for prod in (
- db.query(PlPlanProduct)
- .filter(PlPlanProduct.id.in_(list(plan_product_ids)), PlPlanProduct.del_flag == 0)
- .all()
- ):
- plan_product_by_id[prod.id] = prod
- crm_product_ids = {
- (it.product_id or "").strip() for it in all_items if (it.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, {}
- )
- for record in records:
- products: list[PublicIoOrderHistoryProductRead] = []
- for item in items_by_order.get(record.order_id) or []:
- product_id = (item.product_id or "").strip()
- plan_product = plan_product_by_id.get((item.plan_product_id or "").strip())
- display_name, _alias = resolve_product_display_names(
- saved_product_name=item.product_name,
- product_id=product_id,
- standard_by_id=standard_by_id,
- alias_options_by_id=alias_options_by_id,
- )
- model = (item.specification or "").strip() or (
- (plan_product.model or "").strip() if plan_product else ""
- ) or None
- package_spec = (
- (plan_product.package_spec or "").strip() if plan_product else ""
- ) or None
- qty = item.actual_qty if item.actual_qty is not None else item.plan_qty
- products.append(
- PublicIoOrderHistoryProductRead(
- product_name=display_name or item.product_name or "—",
- model=model,
- package_spec=package_spec,
- quantity=qty,
- unit=_resolve_unit_label(unit_map, item.unit),
- )
- )
- record.products = products
- def _build_page(db: Session, kind: str, header, items) -> dict:
- plan = db.query(PlPlan).filter(PlPlan.id == header.plan_id, PlPlan.del_flag == 0).first()
- voided, archived = _resolve_io_order_link_flags(plan, header)
- data = order_header_to_dict(
- kind, header, items, db=db, public_unit_as_label=True
- )
- data["voided"] = voided
- data["archived"] = archived
- data["title"] = "入库单" if kind == "inbound" else "出库单"
- data["stops"] = []
- if plan and plan_has_load_stop(db, header.tenant_no, plan.id):
- segments = load_io_order_site_stop_segments(
- db,
- tenant_no=header.tenant_no,
- plan_id=plan.id,
- transport_id=header.transport_id,
- )
- data["stops"] = build_public_stop_reads_for_segments(
- db,
- tenant_no=header.tenant_no,
- plan_id=plan.id,
- segments=segments,
- )
- return data
- 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"
- def _make_handlers(kind: str):
- def get_page(token: str, db: Session = Depends(get_db)):
- header, items = _get_by_token(db, kind, token)
- return success_response(_build_page(db, kind, header, items))
- def get_history(
- token: str,
- date: str = Query(..., description="筛选日 YYYY-MM-DD"),
- q: Optional[str] = Query(None, description="车牌号/产品名称模糊"),
- page: int = Query(1, ge=1, description="页码,从 1 开始"),
- page_size: int = Query(20, ge=1, le=500, alias="pageSize", description="每页条数"),
- db: Session = Depends(get_db),
- ):
- try:
- history_page = _build_history_page(
- db,
- entry_kind=kind,
- token=token,
- date_raw=date,
- keyword=q,
- page=page,
- page_size=page_size,
- )
- except HTTPException as exc:
- if exc.status_code == 400:
- return error_response(400, str(exc.detail))
- raise
- return success_response(history_page.model_dump(by_alias=True))
- def save_feedback(token: str, payload: PublicIoOrderFeedback, db: Session = Depends(get_db)):
- header, items = _get_by_token(db, kind, token)
- page = _build_page(db, kind, header, items)
- if page["voided"]:
- return error_response(400, "单据已作废,无法提交")
- if page["archived"]:
- return error_response(400, "运输计划已归档,无法提交")
- use_multi_stop = bool(payload.stops)
- qty_map: dict[str, Decimal] = {}
- brand_qty_map: dict[str, list[dict]] | None = None
- images = payload.images
- try:
- if use_multi_stop:
- qty_map, brand_qty_map, merged_images = apply_io_order_stop_site_feedbacks(
- db,
- kind=kind,
- header=header,
- items=items,
- stops=[stop.model_dump(by_alias=True) for stop in payload.stops],
- enforce_brand=(payload.status == "completed"),
- )
- images = merged_images
- if payload.items:
- for x in payload.items:
- qty_map[x.id] = Decimal(str(x.actual_qty))
- if any(x.brand_qtys is not None for x in payload.items):
- brand_qty_map = brand_qty_map or {}
- for x in payload.items:
- if x.brand_qtys is not None:
- brand_qty_map[x.id] = [
- b.model_dump(by_alias=True) for b in x.brand_qtys
- ]
- else:
- qty_map = {x.id: Decimal(str(x.actual_qty)) for x in payload.items}
- if any(x.brand_qtys is not None for x in payload.items):
- brand_qty_map = {}
- for x in payload.items:
- if x.brand_qtys is not None:
- brand_qty_map[x.id] = [
- b.model_dump(by_alias=True) for b in x.brand_qtys
- ]
- if not images:
- images = parse_feedback_images(header.site_feedback_images)
- apply_site_feedback(
- db,
- kind=kind,
- header=header,
- items=items,
- item_qty=qty_map,
- remark=payload.remark,
- images=images,
- status=payload.status,
- item_brand_qtys=brand_qty_map,
- enforce_brand=(payload.status == "completed")
- and (not use_multi_stop or bool(payload.items)),
- )
- except ValueError as exc:
- return error_response(400, str(exc))
- db.commit()
- return success_response({"success": True, "message": "保存成功"})
- def upload_attachment(
- token: str,
- file: UploadFile = File(...),
- folder: Optional[str] = Form(""),
- db: Session = Depends(get_db),
- ):
- header, _items = _get_by_token(db, kind, token)
- page = _build_page(db, kind, header, _items)
- if page["voided"]:
- return error_response(400, "单据已作废,无法上传附件")
- if page["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_io_order_site_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=header.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_io_order_site_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)}")
- get_page.__name__ = f"get_public_{kind}_order"
- get_history.__name__ = f"get_public_{kind}_history"
- save_feedback.__name__ = f"save_public_{kind}_feedback"
- upload_attachment.__name__ = f"upload_public_{kind}_attachment"
- return get_page, get_history, save_feedback, upload_attachment
- _in_get, _in_hist, _in_fb, _in_up = _make_handlers("inbound")
- _out_get, _out_hist, _out_fb, _out_up = _make_handlers("outbound")
- inbound_public_router.get("/{token}/history")(_in_hist)
- inbound_public_router.get("/{token}")(_in_get)
- inbound_public_router.post("/{token}/feedback")(_in_fb)
- inbound_public_router.post("/{token}/attachments")(_in_up)
- outbound_public_router.get("/{token}/history")(_out_hist)
- outbound_public_router.get("/{token}")(_out_get)
- outbound_public_router.post("/{token}/feedback")(_out_fb)
- outbound_public_router.post("/{token}/attachments")(_out_up)
|