pl_plan_stop_rules.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. """运输计划多段:内部客户地磅默认值与数量平衡校验"""
  2. from __future__ import annotations
  3. from decimal import Decimal
  4. from typing import Any, Iterable, Literal, Optional, Tuple
  5. from fastapi import HTTPException
  6. from app.services.pl_internal_customer import is_internal_customer_name
  7. StopType = Literal["load", "delivery"]
  8. def resolve_stop_dibang_defaults(
  9. customer_name: Optional[str],
  10. stop_type: StopType,
  11. ) -> Tuple[bool, str]:
  12. """地磅默认勾选以本段客户是否为内部客户(山峡/云栋)为准。"""
  13. if not is_internal_customer_name(customer_name):
  14. return False, "none"
  15. if stop_type == "load":
  16. return True, "outbound"
  17. if stop_type == "delivery":
  18. return True, "inbound"
  19. return False, "none"
  20. def normalize_stop_dibang_fields(
  21. customer_name: Optional[str],
  22. stop_type: StopType,
  23. generate_dibang: Optional[int],
  24. dibang_direction: Optional[str],
  25. ) -> Tuple[int, str]:
  26. """归一化段次地磅勾选与方向。
  27. - 非内部客户:强制不生成地磅单
  28. - 内部客户:尊重显式勾选值(含可选车取消勾选传 0);仅 None 时用默认
  29. - 勾选但方向缺失/none 时按本段客户补默认方向
  30. """
  31. if not is_internal_customer_name(customer_name):
  32. return 0, "none"
  33. default_gen, default_dir = resolve_stop_dibang_defaults(customer_name, stop_type)
  34. if generate_dibang is None:
  35. gen = 1 if default_gen else 0
  36. else:
  37. gen = 1 if int(generate_dibang) == 1 else 0
  38. direction = (dibang_direction or "").strip()
  39. if gen:
  40. if direction not in ("outbound", "inbound"):
  41. direction = default_dir if default_dir != "none" else (
  42. "outbound" if stop_type == "load" else "inbound"
  43. )
  44. else:
  45. direction = "none"
  46. return gen, direction
  47. def _extract_qty(item: Any, *field_names: str) -> Decimal:
  48. for name in field_names:
  49. if isinstance(item, dict):
  50. value = item.get(name)
  51. else:
  52. value = getattr(item, name, None)
  53. if value is not None:
  54. return Decimal(str(value))
  55. return Decimal("0")
  56. def _fmt_qty(qty: Decimal) -> str:
  57. if qty == qty.to_integral_value():
  58. return str(int(qty))
  59. return format(qty.normalize(), "f")
  60. def _row_text(item: Any, *field_names: str) -> str:
  61. for name in field_names:
  62. if isinstance(item, dict):
  63. value = item.get(name)
  64. else:
  65. value = getattr(item, name, None)
  66. text = str(value or "").strip()
  67. if text:
  68. return text
  69. return ""
  70. def _product_key(item: Any) -> str:
  71. product_id = _row_text(item, "product_id", "productId")
  72. if product_id:
  73. return f"id:{product_id}"
  74. product_no = _row_text(item, "product_no", "productNo")
  75. if product_no:
  76. return f"no:{product_no}"
  77. product_name = _row_text(item, "product_name", "productName")
  78. if product_name:
  79. return f"name:{product_name}"
  80. return ""
  81. def _product_label(item: Any) -> str:
  82. return _row_text(
  83. item, "product_name", "productName", "product_no", "productNo", "product_id", "productId"
  84. ) or "未命名产品"
  85. def _ordered_product_labels(items: Iterable[Any]) -> list[tuple[str, str]]:
  86. labels: dict[str, str] = {}
  87. order: list[str] = []
  88. for item in items or []:
  89. key = _product_key(item)
  90. if not key or key in labels:
  91. continue
  92. labels[key] = _product_label(item)
  93. order.append(key)
  94. return [(key, labels[key]) for key in order]
  95. def _delivery_product_rows(deliveries: Optional[Iterable[Any]]) -> list[Any]:
  96. rows: list[Any] = []
  97. for delivery in deliveries or []:
  98. if isinstance(delivery, dict):
  99. rows.extend(delivery.get("products") or [])
  100. else:
  101. rows.extend(getattr(delivery, "products", None) or [])
  102. return rows
  103. def load_delivery_product_gap(load: Any, deliveries: Optional[Iterable[Any]]) -> str:
  104. """装货段与全部送货段的产品种类必须一致。返回空字符串表示一致。"""
  105. load_rows = []
  106. if load is not None:
  107. load_rows = load.get("products") if isinstance(load, dict) else (getattr(load, "products", None) or [])
  108. load_labels = _ordered_product_labels(load_rows)
  109. delivery_labels = _ordered_product_labels(_delivery_product_rows(deliveries))
  110. delivery_keys = {key for key, _label in delivery_labels}
  111. load_keys = {key for key, _label in load_labels}
  112. only_load = [label for key, label in load_labels if key not in delivery_keys]
  113. only_delivery = [label for key, label in delivery_labels if key not in load_keys]
  114. parts: list[str] = []
  115. if only_load:
  116. parts.append(f"装货段有产品 {'、'.join(only_load)},送货段没有")
  117. if only_delivery:
  118. parts.append(f"送货段有产品 {'、'.join(only_delivery)},装货段没有")
  119. if not parts:
  120. return ""
  121. return f"{';'.join(parts)}。两边产品必须一致,不能保存"
  122. def assert_load_delivery_same_products(load: Any, deliveries: Optional[Iterable[Any]]) -> None:
  123. gap = load_delivery_product_gap(load, deliveries)
  124. if gap:
  125. raise HTTPException(status_code=422, detail=gap)
  126. def _group_plan_qty(items: Iterable[Any]) -> tuple[list[str], dict[str, tuple[Decimal, str]]]:
  127. groups: dict[str, tuple[Decimal, str]] = {}
  128. order: list[str] = []
  129. for item in items or []:
  130. key = _product_key(item)
  131. qty = _extract_qty(item, "plan_qty", "planQty")
  132. label = _product_label(item) if key else ""
  133. if key not in groups:
  134. groups[key] = (Decimal("0"), label)
  135. order.append(key)
  136. total, existing = groups[key]
  137. groups[key] = (total + qty, existing or label)
  138. return order, groups
  139. def warn_qty_imbalance(
  140. plan_products: Iterable[Any],
  141. load_products: Iterable[Any],
  142. delivery_products: Iterable[Any],
  143. ) -> list[str]:
  144. del plan_products
  145. load_order, load_groups = _group_plan_qty(load_products)
  146. delivery_order, delivery_groups = _group_plan_qty(delivery_products)
  147. warnings: list[str] = []
  148. seen: set[str] = set()
  149. for key in [*load_order, *delivery_order]:
  150. if key in seen:
  151. continue
  152. seen.add(key)
  153. load_qty, load_label = load_groups.get(key, (Decimal("0"), ""))
  154. delivery_qty, delivery_label = delivery_groups.get(key, (Decimal("0"), ""))
  155. if load_qty == delivery_qty:
  156. continue
  157. label = load_label or delivery_label
  158. text = f"装货合计({_fmt_qty(load_qty)})与送货合计({_fmt_qty(delivery_qty)})不一致,请核对"
  159. if label:
  160. text = f"产品{label}:{text}"
  161. warnings.append(text)
  162. return warnings