执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。 Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
6.6 KiB
Python
196 lines
6.6 KiB
Python
"""运输计划多段:内部客户地磅默认值与数量平衡校验"""
|
||
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
|