"""运输计划归档导出:按装货/送货段 × 段内产品展开,与列表展示规则一致。""" from __future__ import annotations from datetime import date, datetime from decimal import Decimal from typing import Optional, Union from sqlalchemy.orm import Session from app.models.pl_models import PlPlan, PlPlanProduct, PlTransport from app.schemas.pl.plan import PlanDeliveryRead, PlanLoadRead, PlanStopProductRead from app.services.pl_internal_customer import is_internal_customer_name from app.services.pl_plan_stop_service import load_plan_stops_grouped StopKind = str # "load" | "delivery" PLAN_TYPE_LABELS = { "direct": "直销单", "inbound": "入库单", "outbound": "出库单", } ARCHIVE_STATUS_LABELS = { "archived": "已归档", "unarchived": "未归档", "draft": "已暂存", } FOLLOW_STATUS_LABELS = { "wait_notice": "等通知", "pending_arrange": "待安排", "arranged": "已安排", "completed": "已完成", "cancelled": "已取消", "draft": "待安排", "processing": "进行中", } FREIGHT_TYPE_LABELS = { "by_weight": "按重量", "by_trip": "按趟", } def resolve_archive_list_io_column(kind: StopKind, customer_name: Optional[str]) -> str: """装货/送货段入库出库列方向(与前端 resolveArchiveListIoColumn 一致)。""" internal = is_internal_customer_name(customer_name) if kind == "load": return "outbound" if internal else "inbound" return "inbound" if internal else "outbound" def _format_date_value(value: Optional[Union[date, datetime, str]]) -> str: if value is None: return "" return str(value)[:10] def _format_plate_display(license_plate: Optional[str], trailer_no: Optional[str]) -> str: plate = (license_plate or "").strip() trailer = (trailer_no or "").strip() if not plate and not trailer: return "" if plate and trailer: return f"{plate}{trailer}" return plate or trailer def _get_stop_archive_actual_qty(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]: return product.inbound_qty if kind == "load" else product.outbound_qty def _get_stop_archive_unit_price(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]: return product.inbound_price if kind == "load" else product.outbound_price def _get_stop_archive_amount(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]: stored = product.inbound_amount if kind == "load" else product.outbound_amount if stored is not None: return stored qty = _get_stop_archive_actual_qty(product, kind) price = _get_stop_archive_unit_price(product, kind) if qty is not None and price is not None: return qty * price return None def _stop_kind_label(kind: StopKind, delivery_seq: Optional[int] = None) -> str: if kind == "load": return "装货" return f"送货 #{delivery_seq or 1}" def _stop_time(kind: StopKind, stop: Union[PlanLoadRead, PlanDeliveryRead]) -> str: if kind == "load": return _format_date_value(getattr(stop, "load_time", None)) return _format_date_value(getattr(stop, "delivery_time", None)) def _load_legacy_products_by_plan( db: Session, tenant_no: str, plan_ids: list[str] ) -> dict[str, list[PlPlanProduct]]: if not plan_ids: return {} products = ( db.query(PlPlanProduct) .filter( PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.del_flag == 0, PlPlanProduct.plan_id.in_(plan_ids), ) .order_by(PlPlanProduct.order_num) .all() ) grouped: dict[str, list[PlPlanProduct]] = {} for product in products: grouped.setdefault(product.plan_id, []).append(product) return grouped def _plan_common_export_fields( plan: PlPlan, transport: Optional[PlTransport], ) -> dict: freight_type_raw = transport.freight_type if transport else "by_weight" freight_type_label = FREIGHT_TYPE_LABELS.get(freight_type_raw, freight_type_raw or "") freight_price = transport.freight_price if transport else None freight_amount = plan.freight_amount if freight_amount is None and transport is not None: freight_amount = transport.freight_amount plan_date = _format_date_value(plan.plan_date) return { "planNo": plan.plan_no or "", "planDate": plan_date, "archiveStatus": ARCHIVE_STATUS_LABELS.get(plan.archive_status or "", plan.archive_status or ""), "followStatus": FOLLOW_STATUS_LABELS.get(plan.follow_status or "", plan.follow_status or ""), "planType": PLAN_TYPE_LABELS.get(plan.plan_type or "", plan.plan_type or ""), "licensePlate": _format_plate_display(plan.license_plate, plan.trailer_no), "erpEntered": "是" if int(plan.erp_entered or 0) == 1 else "否", "freightType": freight_type_label, "freightPrice": freight_price, "freightAmount": freight_amount, "remark": plan.remark or "", } def _empty_product_fields() -> dict: return { "productName": "", "model": "", "packageSpec": "", "unit": "", "planQty": None, "actualQty": None, "inboundQty": None, "outboundQty": None, "unitPrice": None, "amount": None, } def _product_row_from_stop_product( product: PlanStopProductRead, kind: StopKind, customer_name: Optional[str], ) -> dict: actual_qty = _get_stop_archive_actual_qty(product, kind) unit_price = _get_stop_archive_unit_price(product, kind) amount = _get_stop_archive_amount(product, kind) io_col = resolve_archive_list_io_column(kind, customer_name) inbound_qty = actual_qty if io_col == "inbound" else None outbound_qty = actual_qty if io_col == "outbound" else None return { "productName": product.product_name or "", "model": product.model or "", "packageSpec": product.package_spec or "", "unit": product.unit or "", "planQty": product.plan_qty, "actualQty": actual_qty, "inboundQty": inbound_qty, "outboundQty": outbound_qty, "unitPrice": unit_price, "amount": amount, } def _append_stop_product_rows( rows: list[dict], row_no: int, plan_common: dict, kind: StopKind, stop: Union[PlanLoadRead, PlanDeliveryRead], delivery_seq: Optional[int], products: list[PlanStopProductRead], ) -> int: customer_name = stop.customer_name or "" stop_common = { **plan_common, "stopKind": _stop_kind_label(kind, delivery_seq), "stopCustomer": customer_name, "stopAddress": stop.address or "", "stopTime": _stop_time(kind, stop), } product_rows = products or [None] for product in product_rows: row_no += 1 row = { **stop_common, "rowNo": row_no, **_empty_product_fields(), } if product is not None: row.update(_product_row_from_stop_product(product, kind, customer_name)) rows.append(row) return row_no def _append_legacy_plan_product_rows( rows: list[dict], row_no: int, plan: PlPlan, plan_common: dict, products: list[PlPlanProduct], ) -> int: """无段表数据时回退计划产品级导出(兼容旧数据)。""" stop_common = { **plan_common, "stopKind": "", "stopCustomer": plan.customer_name or plan.supplier_name or "", "stopAddress": plan.destination_name or "", "stopTime": _format_date_value(plan.delivery_time), } product_rows = products or [None] for product in product_rows: row_no += 1 row = { **stop_common, "rowNo": row_no, **_empty_product_fields(), } if product is not None: row.update( { "productName": product.product_name or "", "model": product.model or "", "packageSpec": product.package_spec or "", "unit": product.unit or "", "planQty": product.quantity, "actualQty": product.actual_qty, "inboundQty": product.inbound_qty, "outboundQty": product.outbound_qty, "unitPrice": product.inbound_price or product.outbound_price, "amount": product.inbound_amount or product.outbound_amount, } ) rows.append(row) return row_no def build_plan_archive_export_rows( db: Session, tenant_no: str, plans: list[PlPlan], transports_by_id: dict[str, PlTransport], ) -> list[dict]: """构建归档导出数据行:每计划 × 每段次 × 每段产品一行。""" if not plans: return [] plan_ids = [plan.id for plan in plans] stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids) legacy_products_by_plan = _load_legacy_products_by_plan(db, tenant_no, plan_ids) rows: list[dict] = [] row_no = 0 for plan in plans: transport = transports_by_id.get(plan.transport_id) if plan.transport_id else None plan_common = _plan_common_export_fields(plan, transport) load_read, delivery_reads = stops_by_plan.get(plan.id, (None, [])) has_stops = load_read is not None or bool(delivery_reads) if not has_stops: row_no = _append_legacy_plan_product_rows( rows, row_no, plan, plan_common, legacy_products_by_plan.get(plan.id, []), ) continue if load_read is not None: row_no = _append_stop_product_rows( rows, row_no, plan_common, "load", load_read, None, load_read.products or [], ) for idx, delivery_read in enumerate(delivery_reads): delivery_seq = delivery_read.seq or (idx + 1) row_no = _append_stop_product_rows( rows, row_no, plan_common, "delivery", delivery_read, delivery_seq, delivery_read.products or [], ) return rows