"""运输计划 · 司机/现场按段回传""" from __future__ import annotations import json from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal from typing import Any, Literal, Optional from sqlalchemy.orm import Session from app.core.config import settings from app.core.utils import generate_uuid from app.models.pl_models import ( PlPlanDelivery, PlPlanDeliveryProduct, PlPlanLoad, PlPlanLoadProduct, PlTransport, PlTransportDriverStopFeedback, PlTransportSiteStopFeedback, ) from app.services.pl_internal_customer import is_internal_customer_name StopType = Literal["load", "delivery"] @dataclass class PublicStopProductRow: stop_product_id: str plan_product_id: str product_id: str product_name: str model: Optional[str] unit: str package_spec: Optional[str] delivery_method: Optional[str] plan_qty: Optional[Decimal] remark: Optional[str] actual_qty: Optional[Decimal] = None brand_qtys: Optional[list[dict[str, Any]]] = None @dataclass class PublicStopSegment: stop_type: StopType stop_id: str seq: int label: str customer_name: str address: Optional[str] contact: Optional[str] notes: Optional[str] business_type: Optional[str] plan_qty: Optional[Decimal] attachment_urls: list[str] remark: Optional[str] feedback_time: Optional[datetime] lng: Optional[Decimal] = None lat: Optional[Decimal] = None quantity: Optional[Decimal] = None products: list[PublicStopProductRow] = field(default_factory=list) def build_transport_site_url(token: str) -> str: origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/") return f"{origin}/#/public/pl/transport-site/{token}" def ensure_transport_site_token(transport: PlTransport) -> str: token = (transport.site_access_token or "").strip() if not token: token = generate_uuid().replace("-", "")[:32] transport.site_access_token = token return token def rotate_transport_site_token(transport: PlTransport) -> str: token = generate_uuid().replace("-", "")[:32] transport.site_access_token = token return token def _parse_urls(raw: Optional[str]) -> list[str]: if not raw: return [] try: parsed = json.loads(raw) if isinstance(parsed, list): return [str(item).strip() for item in parsed if str(item).strip()] except (TypeError, json.JSONDecodeError): pass return [part.strip() for part in raw.split(",") if part.strip()] def _dump_urls(urls: Optional[list[str]]) -> Optional[str]: if urls is None: return None cleaned = [str(item).strip() for item in urls if str(item).strip()] return json.dumps(cleaned, ensure_ascii=False) def _direction_label(direction: Optional[str]) -> Optional[str]: normalized = (direction or "").strip() if normalized == "outbound": return "出库" if normalized == "inbound": return "入库" return None def _sum_stop_qty(products: list[Any]) -> Optional[Decimal]: total = Decimal("0") has_qty = False for row in products: qty = getattr(row, "plan_qty", None) if qty is not None: total += Decimal(str(qty)) has_qty = True return total if has_qty else None def _stop_product_row_from_load(row: PlPlanLoadProduct) -> PublicStopProductRow: return PublicStopProductRow( stop_product_id=row.id, plan_product_id=row.plan_product_id, product_id=row.product_id, product_name=row.product_name or "", model=row.model, unit=(row.unit or "吨").strip() or "吨", package_spec=row.package_spec, delivery_method=row.delivery_method, plan_qty=row.plan_qty, remark=(row.remark or "").strip() or None, ) def _stop_product_row_from_delivery(row: PlPlanDeliveryProduct) -> PublicStopProductRow: return PublicStopProductRow( stop_product_id=row.id, plan_product_id=row.plan_product_id, product_id=row.product_id, product_name=row.product_name or "", model=row.model, unit=(row.unit or "吨").strip() or "吨", package_spec=row.package_spec, delivery_method=row.delivery_method, plan_qty=row.plan_qty, remark=(row.remark or "").strip() or None, ) def _parse_product_feedbacks(raw: Optional[str]) -> dict[str, dict[str, Any]]: if not raw: return {} try: parsed = json.loads(raw) except (TypeError, json.JSONDecodeError): return {} if not isinstance(parsed, list): return {} result: dict[str, dict[str, Any]] = {} for item in parsed: if not isinstance(item, dict): continue stop_product_id = str( item.get("stopProductId") or item.get("stop_product_id") or "" ).strip() if not stop_product_id: continue result[stop_product_id] = item return result def _dump_product_feedbacks(products: Optional[list[dict[str, Any]]]) -> Optional[str]: if products is None: return None cleaned: list[dict[str, Any]] = [] for item in products: stop_product_id = str( item.get("stopProductId") or item.get("stop_product_id") or "" ).strip() if not stop_product_id: continue actual_qty = item.get("actualQty", item.get("actual_qty")) row: dict[str, Any] = { "stopProductId": stop_product_id, "actualQty": float(Decimal(str(actual_qty or 0))), } brand_qtys = item.get("brandQtys") or item.get("brand_qtys") if brand_qtys: row["brandQtys"] = brand_qtys cleaned.append(row) return json.dumps(cleaned, ensure_ascii=False) def _merge_product_feedbacks_into_segment( segment: PublicStopSegment, feedback_map: dict[str, dict[str, Any]], ) -> None: if not feedback_map or not segment.products: return for product in segment.products: item = feedback_map.get(product.stop_product_id) if not item: continue actual = item.get("actualQty", item.get("actual_qty")) if actual is not None: product.actual_qty = Decimal(str(actual)) brand_qtys = item.get("brandQtys") or item.get("brand_qtys") if brand_qtys: product.brand_qtys = brand_qtys if isinstance(brand_qtys, list) else None def _is_site_visible_stop(customer_name: Optional[str], generate_dibang: int) -> bool: return is_internal_customer_name(customer_name) and int(generate_dibang or 0) == 1 def list_plan_stop_segments( db: Session, *, tenant_no: str, plan_id: str, include_all_for_driver: bool = True, site_internal_only: bool = False, ) -> list[PublicStopSegment]: segments: list[PublicStopSegment] = [] load = ( db.query(PlPlanLoad) .filter( PlPlanLoad.plan_id == plan_id, PlPlanLoad.tenant_no == tenant_no, PlPlanLoad.del_flag == 0, ) .first() ) if load: products = ( db.query(PlPlanLoadProduct) .filter( PlPlanLoadProduct.load_id == load.id, PlPlanLoadProduct.tenant_no == tenant_no, PlPlanLoadProduct.del_flag == 0, ) .all() ) if include_all_for_driver or ( site_internal_only and _is_site_visible_stop(load.customer_name, load.generate_dibang) ): stop_products = [ _stop_product_row_from_load(row) for row in sorted( products, key=lambda item: (item.order_num or 0, item.create_time or datetime.min), ) ] segments.append( PublicStopSegment( stop_type="load", stop_id=load.id, seq=0, label="装货", customer_name=(load.customer_name or "").strip(), address=load.address, contact=load.contact, notes=load.notes, lng=load.lng, lat=load.lat, business_type=_direction_label(load.dibang_direction), plan_qty=_sum_stop_qty(products), attachment_urls=[], remark=None, feedback_time=None, products=stop_products, ) ) deliveries = ( db.query(PlPlanDelivery) .filter( PlPlanDelivery.plan_id == plan_id, PlPlanDelivery.tenant_no == tenant_no, PlPlanDelivery.del_flag == 0, ) .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc()) .all() ) for delivery in deliveries: products = ( db.query(PlPlanDeliveryProduct) .filter( PlPlanDeliveryProduct.delivery_id == delivery.id, PlPlanDeliveryProduct.tenant_no == tenant_no, PlPlanDeliveryProduct.del_flag == 0, ) .all() ) if include_all_for_driver or ( site_internal_only and _is_site_visible_stop(delivery.customer_name, delivery.generate_dibang) ): stop_products = [ _stop_product_row_from_delivery(row) for row in sorted( products, key=lambda item: (item.order_num or 0, item.create_time or datetime.min), ) ] segments.append( PublicStopSegment( stop_type="delivery", stop_id=delivery.id, seq=int(delivery.seq or 0), label=f"送货 #{delivery.seq or len(segments)}", customer_name=(delivery.customer_name or "").strip(), address=delivery.address, contact=delivery.contact, notes=delivery.notes, lng=delivery.lng, lat=delivery.lat, business_type=_direction_label(delivery.dibang_direction), plan_qty=_sum_stop_qty(products), attachment_urls=[], remark=None, feedback_time=None, products=stop_products, ) ) return segments def _merge_driver_feedback( segments: list[PublicStopSegment], feedback_rows: list[PlTransportDriverStopFeedback], ) -> None: feedback_map = {(row.stop_type, row.stop_id): row for row in feedback_rows} for segment in segments: row = feedback_map.get((segment.stop_type, segment.stop_id)) if not row: continue segment.attachment_urls = _parse_urls(row.attachment_urls) segment.remark = row.remark segment.feedback_time = row.feedback_time _merge_product_feedbacks_into_segment( segment, _parse_product_feedbacks(row.product_feedbacks), ) def _merge_site_feedback( segments: list[PublicStopSegment], feedback_rows: list[PlTransportSiteStopFeedback], ) -> None: feedback_map = {(row.stop_type, row.stop_id): row for row in feedback_rows} for segment in segments: row = feedback_map.get((segment.stop_type, segment.stop_id)) if not row: continue segment.quantity = row.quantity segment.attachment_urls = _parse_urls(row.attachment_urls) segment.remark = row.remark segment.feedback_time = row.feedback_time if row.business_type: segment.business_type = row.business_type _merge_product_feedbacks_into_segment( segment, _parse_product_feedbacks(row.product_feedbacks), ) def load_driver_stop_segments( db: Session, *, tenant_no: str, plan_id: str, transport_id: str, ) -> list[PublicStopSegment]: segments = list_plan_stop_segments( db, tenant_no=tenant_no, plan_id=plan_id, include_all_for_driver=True, site_internal_only=False, ) rows = ( db.query(PlTransportDriverStopFeedback) .filter( PlTransportDriverStopFeedback.transport_id == transport_id, PlTransportDriverStopFeedback.tenant_no == tenant_no, PlTransportDriverStopFeedback.del_flag == 0, ) .all() ) _merge_driver_feedback(segments, rows) return segments def _load_site_stop_feedback_rows( db: Session, *, tenant_no: str, transport_id: str, ) -> list[PlTransportSiteStopFeedback]: return ( db.query(PlTransportSiteStopFeedback) .filter( PlTransportSiteStopFeedback.transport_id == transport_id, PlTransportSiteStopFeedback.tenant_no == tenant_no, PlTransportSiteStopFeedback.del_flag == 0, ) .all() ) def load_site_stop_segments( db: Session, *, tenant_no: str, plan_id: str, transport_id: str, ) -> list[PublicStopSegment]: segments = list_plan_stop_segments( db, tenant_no=tenant_no, plan_id=plan_id, include_all_for_driver=False, site_internal_only=True, ) _merge_site_feedback(segments, _load_site_stop_feedback_rows(db, tenant_no=tenant_no, transport_id=transport_id)) return segments def load_io_order_site_stop_segments( db: Session, *, tenant_no: str, plan_id: str, transport_id: str, ) -> list[PublicStopSegment]: """入库/出库单现场页:展示全部装货/送货段及段内产品。""" segments = list_plan_stop_segments( db, tenant_no=tenant_no, plan_id=plan_id, include_all_for_driver=True, site_internal_only=False, ) _merge_site_feedback(segments, _load_site_stop_feedback_rows(db, tenant_no=tenant_no, transport_id=transport_id)) return segments def _get_driver_feedback_row( db: Session, *, tenant_no: str, transport_id: str, stop_type: str, stop_id: str, ) -> Optional[PlTransportDriverStopFeedback]: return ( db.query(PlTransportDriverStopFeedback) .filter( PlTransportDriverStopFeedback.transport_id == transport_id, PlTransportDriverStopFeedback.tenant_no == tenant_no, PlTransportDriverStopFeedback.stop_type == stop_type, PlTransportDriverStopFeedback.stop_id == stop_id, PlTransportDriverStopFeedback.del_flag == 0, ) .first() ) def save_driver_stop_feedback( db: Session, *, tenant_no: str, transport_id: str, plan_id: str, stop_type: str, stop_id: str, attachment_urls: list[str], products: Optional[list[dict[str, Any]]] = None, remark: Optional[str] = None, user_id: Optional[str] = None, dept_id: str = "", ) -> PlTransportDriverStopFeedback: row = _get_driver_feedback_row( db, tenant_no=tenant_no, transport_id=transport_id, stop_type=stop_type, stop_id=stop_id, ) now = datetime.now() product_feedbacks_dump = _dump_product_feedbacks(products) if row: row.attachment_urls = _dump_urls(attachment_urls) if product_feedbacks_dump is not None: row.product_feedbacks = product_feedbacks_dump if remark is not None: row.remark = remark row.feedback_time = now row.update_by = user_id else: row = PlTransportDriverStopFeedback( id=generate_uuid(), tenant_no=tenant_no, transport_id=transport_id, plan_id=plan_id, stop_type=stop_type, stop_id=stop_id, attachment_urls=_dump_urls(attachment_urls), product_feedbacks=product_feedbacks_dump, remark=remark, feedback_time=now, create_by=user_id, create_dept_id=dept_id, ) db.add(row) db.flush() return row def _get_site_feedback_row( db: Session, *, tenant_no: str, transport_id: str, stop_type: str, stop_id: str, ) -> Optional[PlTransportSiteStopFeedback]: return ( db.query(PlTransportSiteStopFeedback) .filter( PlTransportSiteStopFeedback.transport_id == transport_id, PlTransportSiteStopFeedback.tenant_no == tenant_no, PlTransportSiteStopFeedback.stop_type == stop_type, PlTransportSiteStopFeedback.stop_id == stop_id, PlTransportSiteStopFeedback.del_flag == 0, ) .first() ) def _sum_product_actual_qty(products: Optional[list[dict[str, Any]]]) -> Optional[Decimal]: if not products: return None total = Decimal("0") has_qty = False for item in products: actual = item.get("actualQty", item.get("actual_qty")) if actual is None: continue qty = Decimal(str(actual)) if qty > 0: total += qty has_qty = True return total if has_qty else None def _has_positive_feedback_qty( quantity: Optional[Decimal], products: Optional[list[dict[str, Any]]], ) -> bool: product_total = _sum_product_actual_qty(products) if product_total is not None: return product_total > 0 return quantity is not None and quantity > 0 def save_site_stop_feedback( db: Session, *, tenant_no: str, transport_id: str, plan_id: str, stop_type: str, stop_id: str, business_type: str, quantity: Optional[Decimal] = None, attachment_urls: Optional[list[str]] = None, products: Optional[list[dict[str, Any]]] = None, remark: Optional[str] = None, user_id: Optional[str] = None, dept_id: str = "", require_qty_before_attachments: bool = True, ) -> PlTransportSiteStopFeedback: product_feedbacks_dump = _dump_product_feedbacks(products) effective_qty = quantity if products is not None: effective_qty = _sum_product_actual_qty(products) if require_qty_before_attachments and attachment_urls and not _has_positive_feedback_qty( effective_qty, products ): raise ValueError("请先填写数量,再上传附件") row = _get_site_feedback_row( db, tenant_no=tenant_no, transport_id=transport_id, stop_type=stop_type, stop_id=stop_id, ) now = datetime.now() if row: if effective_qty is not None: row.quantity = effective_qty if attachment_urls is not None: row.attachment_urls = _dump_urls(attachment_urls) if product_feedbacks_dump is not None: row.product_feedbacks = product_feedbacks_dump if remark is not None: row.remark = remark row.feedback_time = now row.update_by = user_id else: row = PlTransportSiteStopFeedback( id=generate_uuid(), tenant_no=tenant_no, transport_id=transport_id, plan_id=plan_id, stop_type=stop_type, stop_id=stop_id, business_type=business_type, quantity=effective_qty, attachment_urls=_dump_urls(attachment_urls or []), product_feedbacks=product_feedbacks_dump, remark=remark, feedback_time=now, create_by=user_id, create_dept_id=dept_id, ) db.add(row) db.flush() return row def list_driver_stop_feedback_reads( db: Session, *, tenant_no: str, transport_id: str, plan_id: str, ) -> list[dict[str, Any]]: segments = load_driver_stop_segments( db, tenant_no=tenant_no, plan_id=plan_id, transport_id=transport_id, ) return [ { "stopType": segment.stop_type, "stopId": segment.stop_id, "label": segment.label, "customerName": segment.customer_name, "attachmentUrls": segment.attachment_urls, "products": [ { "stopProductId": product.stop_product_id, "planProductId": product.plan_product_id, "productName": product.product_name, "model": product.model, "unit": product.unit, "packageSpec": product.package_spec, "quantity": float(product.plan_qty) if product.plan_qty is not None else None, "actualQty": float(product.actual_qty) if product.actual_qty is not None else None, "brandQtys": product.brand_qtys, } 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, "feedbackTime": segment.feedback_time, } for segment in segments 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 ) ] def list_site_stop_feedback_reads( db: Session, *, tenant_no: str, transport_id: str, plan_id: str, ) -> list[dict[str, Any]]: segments = load_site_stop_segments( db, tenant_no=tenant_no, plan_id=plan_id, transport_id=transport_id, ) return [ { "stopType": segment.stop_type, "stopId": segment.stop_id, "label": segment.label, "customerName": segment.customer_name, "businessType": segment.business_type, "quantity": float(segment.quantity) if segment.quantity is not None else None, "attachmentUrls": segment.attachment_urls, "products": [ { "stopProductId": product.stop_product_id, "planProductId": product.plan_product_id, "productName": product.product_name, "model": product.model, "unit": product.unit, "packageSpec": product.package_spec, "quantity": float(product.plan_qty) if product.plan_qty is not None else None, "actualQty": float(product.actual_qty) if product.actual_qty is not None else None, "brandQtys": product.brand_qtys, } 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, "feedbackTime": segment.feedback_time, } for segment in segments 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 ) ] 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 build_public_stop_reads_for_segments( db: Session, *, tenant_no: str, plan_id: str, segments: list[PublicStopSegment], ) -> list[dict[str, Any]]: """为公共页组装装货/送货段及段内产品(含别名、品牌选项)。""" from app.models.crm_models import CrmProductBrand from app.models.pl_models import PlPlanProduct from app.schemas.pl.transport import BrandOption, PublicTransportStopProductRead, PublicTransportStopRead 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 plan_products = ( db.query(PlPlanProduct) .filter( PlPlanProduct.plan_id == plan_id, PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.del_flag == 0, ) .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc()) .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() } 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 == 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 "") ) 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, {} ) reads: list[PublicTransportStopRead] = [] for segment in segments: show_product_qty = is_internal_customer_name(segment.customer_name) product_reads: list[PublicTransportStopProductRead] = [] if show_product_qty: 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, ) brand_qtys = None if row.brand_qtys: from app.schemas.pl.transport import BrandQtyItem 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 row.brand_qtys if isinstance(item, dict) ] or None product_reads.append( PublicTransportStopProductRead( stop_product_id=row.stop_product_id, plan_product_id=row.plan_product_id, product_name=display_name, alias_name=alias_name, model=row.model, package_spec=row.package_spec, unit=_resolve_unit_label(unit_map, row.unit), quantity=row.plan_qty, remark=row.remark, brand_mode=plan_prod.brand_mode if plan_prod else None, brand_id=plan_prod.brand_id if plan_prod else None, brand_name=plan_prod.brand_name if plan_prod else None, brand_options=brand_options_by_product.get(product_id) or None, actual_qty=row.actual_qty, brand_qtys=brand_qtys, ) ) reads.append( PublicTransportStopRead( stop_type=segment.stop_type, stop_id=segment.stop_id, seq=segment.seq, label=segment.label, customer_name=segment.customer_name, address=segment.address, contact=segment.contact, notes=segment.notes, lng=segment.lng, lat=segment.lat, business_type=segment.business_type, plan_qty=segment.plan_qty, quantity=segment.quantity, show_product_qty=show_product_qty, products=product_reads, attachment_urls=segment.attachment_urls, remark=segment.remark, feedback_time=segment.feedback_time, ) ) return [row.model_dump(by_alias=True) for row in reads]