"""运输计划多段:内部客户地磅默认值与数量平衡校验""" from __future__ import annotations from decimal import Decimal from typing import Any, Iterable, Literal, Optional, Tuple from fastapi import HTTPException from app.services.pl_internal_customer import is_internal_customer_name StopType = Literal["load", "delivery"] def resolve_stop_dibang_defaults( customer_name: Optional[str], stop_type: StopType, ) -> Tuple[bool, str]: """地磅默认勾选以本段客户是否为内部客户(山峡/云栋)为准。""" if not is_internal_customer_name(customer_name): return False, "none" if stop_type == "load": return True, "outbound" if stop_type == "delivery": return True, "inbound" return False, "none" def normalize_stop_dibang_fields( customer_name: Optional[str], stop_type: StopType, generate_dibang: Optional[int], dibang_direction: Optional[str], ) -> Tuple[int, str]: """归一化段次地磅勾选与方向。 - 非内部客户:强制不生成地磅单 - 内部客户:尊重显式勾选值(含可选车取消勾选传 0);仅 None 时用默认 - 勾选但方向缺失/none 时按本段客户补默认方向 """ if not is_internal_customer_name(customer_name): return 0, "none" default_gen, default_dir = resolve_stop_dibang_defaults(customer_name, stop_type) if generate_dibang is None: gen = 1 if default_gen else 0 else: gen = 1 if int(generate_dibang) == 1 else 0 direction = (dibang_direction or "").strip() if gen: if direction not in ("outbound", "inbound"): direction = default_dir if default_dir != "none" else ( "outbound" if stop_type == "load" else "inbound" ) else: direction = "none" return gen, direction def _extract_qty(item: Any, *field_names: str) -> Decimal: for name in field_names: if isinstance(item, dict): value = item.get(name) else: value = getattr(item, name, None) if value is not None: return Decimal(str(value)) return Decimal("0") def _fmt_qty(qty: Decimal) -> str: if qty == qty.to_integral_value(): return str(int(qty)) return format(qty.normalize(), "f") def _row_text(item: Any, *field_names: str) -> str: for name in field_names: if isinstance(item, dict): value = item.get(name) else: value = getattr(item, name, None) text = str(value or "").strip() if text: return text return "" def _product_key(item: Any) -> str: product_id = _row_text(item, "product_id", "productId") if product_id: return f"id:{product_id}" product_no = _row_text(item, "product_no", "productNo") if product_no: return f"no:{product_no}" product_name = _row_text(item, "product_name", "productName") if product_name: return f"name:{product_name}" return "" def _product_label(item: Any) -> str: return _row_text( item, "product_name", "productName", "product_no", "productNo", "product_id", "productId" ) or "未命名产品" def _ordered_product_labels(items: Iterable[Any]) -> list[tuple[str, str]]: labels: dict[str, str] = {} order: list[str] = [] for item in items or []: key = _product_key(item) if not key or key in labels: continue labels[key] = _product_label(item) order.append(key) return [(key, labels[key]) for key in order] def _delivery_product_rows(deliveries: Optional[Iterable[Any]]) -> list[Any]: rows: list[Any] = [] for delivery in deliveries or []: if isinstance(delivery, dict): rows.extend(delivery.get("products") or []) else: rows.extend(getattr(delivery, "products", None) or []) return rows def load_delivery_product_gap(load: Any, deliveries: Optional[Iterable[Any]]) -> str: """装货段与全部送货段的产品种类必须一致。返回空字符串表示一致。""" load_rows = [] if load is not None: load_rows = load.get("products") if isinstance(load, dict) else (getattr(load, "products", None) or []) load_labels = _ordered_product_labels(load_rows) delivery_labels = _ordered_product_labels(_delivery_product_rows(deliveries)) delivery_keys = {key for key, _label in delivery_labels} load_keys = {key for key, _label in load_labels} only_load = [label for key, label in load_labels if key not in delivery_keys] only_delivery = [label for key, label in delivery_labels if key not in load_keys] parts: list[str] = [] if only_load: parts.append(f"装货段有产品 {'、'.join(only_load)},送货段没有") if only_delivery: parts.append(f"送货段有产品 {'、'.join(only_delivery)},装货段没有") if not parts: return "" return f"{';'.join(parts)}。两边产品必须一致,不能保存" def assert_load_delivery_same_products(load: Any, deliveries: Optional[Iterable[Any]]) -> None: gap = load_delivery_product_gap(load, deliveries) if gap: raise HTTPException(status_code=422, detail=gap) def _group_plan_qty(items: Iterable[Any]) -> tuple[list[str], dict[str, tuple[Decimal, str]]]: groups: dict[str, tuple[Decimal, str]] = {} order: list[str] = [] for item in items or []: key = _product_key(item) qty = _extract_qty(item, "plan_qty", "planQty") label = _product_label(item) if key else "" if key not in groups: groups[key] = (Decimal("0"), label) order.append(key) total, existing = groups[key] groups[key] = (total + qty, existing or label) return order, groups def warn_qty_imbalance( plan_products: Iterable[Any], load_products: Iterable[Any], delivery_products: Iterable[Any], ) -> list[str]: del plan_products load_order, load_groups = _group_plan_qty(load_products) delivery_order, delivery_groups = _group_plan_qty(delivery_products) warnings: list[str] = [] seen: set[str] = set() for key in [*load_order, *delivery_order]: if key in seen: continue seen.add(key) load_qty, load_label = load_groups.get(key, (Decimal("0"), "")) delivery_qty, delivery_label = delivery_groups.get(key, (Decimal("0"), "")) if load_qty == delivery_qty: continue label = load_label or delivery_label text = f"装货合计({_fmt_qty(load_qty)})与送货合计({_fmt_qty(delivery_qty)})不一致,请核对" if label: text = f"产品{label}:{text}" warnings.append(text) return warnings