Files
shanxia2.0/backend/app/services/pl_plan_stop_rules.py
T
wuzilongandCursor bde19c0644 feat: 锁定执行中计划并按装卸明细差量归档库存,补齐派车日历数量与酸碱类型。
执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 07:59:53 +08:00

196 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运输计划多段:内部客户地磅默认值与数量平衡校验"""
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