1970 lines
66 KiB
Python
1970 lines
66 KiB
Python
"""合同产品在运输计划上的安排数量汇总。"""
|
||
from __future__ import annotations
|
||
|
||
from collections import defaultdict
|
||
from typing import Any, DefaultDict, Dict, Iterable, Optional, Set, Tuple
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.pl_models import (
|
||
PlPlan,
|
||
PlPlanContract,
|
||
PlPlanDelivery,
|
||
PlPlanDeliveryProduct,
|
||
PlPlanLoad,
|
||
PlPlanLoadProduct,
|
||
)
|
||
from app.services.pl_plan_service import (
|
||
PLAN_CONTRACT_SIDE_DEMANDER,
|
||
PLAN_CONTRACT_SIDE_SUPPLIER,
|
||
)
|
||
from app.services.pl_plan_stop_service import (
|
||
PLAN_CONTRACT_SIDE_DELIVERY,
|
||
PLAN_CONTRACT_SIDE_LOAD,
|
||
)
|
||
from app.utils.crm_package_spec import decode_package_specs
|
||
|
||
# 待安排 / 等通知 / 作废:不计入「已安排」(见 _bucket_for_plan)
|
||
|
||
ProductPackageKey = Tuple[str, str]
|
||
LINE_OCCUPANCY_PREFIX = "__line__"
|
||
PKG_DISPLAY_PREFIX = "__pkg__"
|
||
|
||
|
||
def line_occupancy_key(contract_product_id: Any) -> Optional[ProductPackageKey]:
|
||
cpid = str(contract_product_id or "").strip()
|
||
if not cpid:
|
||
return None
|
||
return (LINE_OCCUPANCY_PREFIX, cpid)
|
||
|
||
|
||
def is_line_occupancy_key(key: Any) -> bool:
|
||
return (
|
||
isinstance(key, tuple)
|
||
and len(key) == 2
|
||
and key[0] == LINE_OCCUPANCY_PREFIX
|
||
and bool(str(key[1] or "").strip())
|
||
)
|
||
|
||
|
||
def normalize_package_spec(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
return str(value).strip()
|
||
|
||
|
||
def product_package_key(product_id: Any, package_spec: Any = None) -> Optional[ProductPackageKey]:
|
||
pid = str(product_id or "").strip()
|
||
if not pid:
|
||
return None
|
||
return (pid, normalize_package_spec(package_spec))
|
||
|
||
|
||
def package_display_key(contract_product_id: Any, package_spec: Any = None) -> Optional[ProductPackageKey]:
|
||
"""按合同明细行拆开展示包装数量;不参与行剩余占用,避免与 __line__ 双计。"""
|
||
cpid = str(contract_product_id or "").strip()
|
||
if not cpid:
|
||
return None
|
||
return (PKG_DISPLAY_PREFIX, f"{cpid}\x1f{normalize_package_spec(package_spec)}")
|
||
|
||
|
||
def _stats_for_package_breakdown(
|
||
qty_map: Dict[ProductPackageKey, Dict[str, float]],
|
||
pool_key: Optional[ProductPackageKey],
|
||
zeros: Dict[str, float],
|
||
*,
|
||
contract_product_id: Any = None,
|
||
include_pool_key: bool = True,
|
||
) -> Dict[str, float]:
|
||
out = {k: 0.0 for k in zeros}
|
||
if pool_key:
|
||
dkey = package_display_key(contract_product_id, pool_key[1])
|
||
sources = []
|
||
if dkey:
|
||
sources.append(qty_map.get(dkey))
|
||
if include_pool_key:
|
||
sources.append(qty_map.get(pool_key))
|
||
for src in sources:
|
||
if not src:
|
||
continue
|
||
for k in zeros:
|
||
out[k] = round(out[k] + _to_float(src.get(k)), 3)
|
||
return out
|
||
|
||
|
||
def _to_float(value: Any) -> float:
|
||
if value is None:
|
||
return 0.0
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
|
||
def contract_line_package_specs(row: Any) -> list[str]:
|
||
"""从合同产品行(ORM / dict / SimpleNamespace)解析包装列表。"""
|
||
if isinstance(row, dict):
|
||
raw_list = row.get("packageSpecs") or row.get("package_specs")
|
||
if isinstance(raw_list, (list, tuple)) and raw_list:
|
||
return decode_package_specs(raw_list)
|
||
return decode_package_specs(row.get("packageSpec") or row.get("package_spec"))
|
||
raw_list = getattr(row, "package_specs", None)
|
||
if isinstance(raw_list, (list, tuple)) and raw_list:
|
||
return decode_package_specs(raw_list)
|
||
return decode_package_specs(getattr(row, "package_spec", None))
|
||
|
||
|
||
def pool_keys_for_contract_line(row: Any) -> list[ProductPackageKey]:
|
||
pid = ""
|
||
if isinstance(row, dict):
|
||
pid = str(row.get("productId") or row.get("product_id") or "").strip()
|
||
else:
|
||
pid = str(getattr(row, "product_id", None) or "").strip()
|
||
if not pid:
|
||
return []
|
||
keys: list[ProductPackageKey] = []
|
||
for spec in contract_line_package_specs(row):
|
||
key = product_package_key(pid, spec)
|
||
if key:
|
||
keys.append(key)
|
||
return keys
|
||
|
||
|
||
def sum_qty_stats_for_keys(
|
||
qty_map: Dict[ProductPackageKey, Dict[str, float]],
|
||
keys: Iterable[ProductPackageKey],
|
||
) -> Dict[str, float]:
|
||
archived = 0.0
|
||
unarchived = 0.0
|
||
for key in keys or []:
|
||
stats = qty_map.get(key) or {}
|
||
archived += _to_float(stats.get("archivedQty"))
|
||
unarchived += _to_float(stats.get("arrangedUnarchivedQty"))
|
||
archived = round(archived, 3)
|
||
unarchived = round(unarchived, 3)
|
||
return {
|
||
"archivedQty": archived,
|
||
"arrangedUnarchivedQty": unarchived,
|
||
"arrangedTotalQty": round(archived + unarchived, 3),
|
||
}
|
||
|
||
|
||
def contract_product_id_of(row: Any) -> str:
|
||
if isinstance(row, dict):
|
||
return str(row.get("id") or row.get("contractProductId") or "").strip()
|
||
return str(getattr(row, "id", None) or getattr(row, "contract_product_id", None) or "").strip()
|
||
|
||
|
||
def match_first_contract_product_for_key(
|
||
products: Iterable[Any],
|
||
key: Optional[ProductPackageKey],
|
||
) -> Optional[Any]:
|
||
"""按 productId + 包装∈行内集合匹配第一行(同包装多行不合并)。"""
|
||
if not key:
|
||
return None
|
||
pid, pkg = key[0], key[1]
|
||
for p in products or []:
|
||
if isinstance(p, dict):
|
||
row_pid = str(p.get("productId") or p.get("product_id") or "").strip()
|
||
else:
|
||
row_pid = str(getattr(p, "product_id", None) or "").strip()
|
||
if row_pid != pid:
|
||
continue
|
||
specs = contract_line_package_specs(p)
|
||
if pkg in specs:
|
||
return p
|
||
return None
|
||
|
||
|
||
def occupancy_keys_for_contract_line(
|
||
row: Any,
|
||
all_products: Optional[Iterable[Any]] = None,
|
||
) -> list[ProductPackageKey]:
|
||
"""行占用键:绑定行用 __line__;无绑定时仅第一匹配行承接包装键(历史计划)。"""
|
||
keys: list[ProductPackageKey] = []
|
||
cpid = contract_product_id_of(row)
|
||
line_key = line_occupancy_key(cpid)
|
||
if line_key:
|
||
keys.append(line_key)
|
||
peers = list(all_products or [])
|
||
for pkg_key in pool_keys_for_contract_line(row):
|
||
first = match_first_contract_product_for_key(peers, pkg_key) if peers else row
|
||
if first is None:
|
||
continue
|
||
if cpid and contract_product_id_of(first) != cpid:
|
||
continue
|
||
keys.append(pkg_key)
|
||
return keys
|
||
|
||
|
||
def match_contract_product_for_key(
|
||
products: Iterable[Any],
|
||
key: Optional[ProductPackageKey],
|
||
) -> Optional[Any]:
|
||
"""按 productId + 包装∈行内集合 匹配合同产品行;同包装脏数据多行时 quantity 相加。"""
|
||
if not key:
|
||
return None
|
||
pid, pkg = key[0], key[1]
|
||
matched = []
|
||
for p in products or []:
|
||
row_pid = ""
|
||
if isinstance(p, dict):
|
||
row_pid = str(p.get("productId") or p.get("product_id") or "").strip()
|
||
else:
|
||
row_pid = str(getattr(p, "product_id", None) or "").strip()
|
||
if row_pid != pid:
|
||
continue
|
||
specs = contract_line_package_specs(p)
|
||
if pkg in specs:
|
||
matched.append(p)
|
||
if not matched:
|
||
return None
|
||
if len(matched) == 1:
|
||
return matched[0]
|
||
from types import SimpleNamespace
|
||
|
||
total_qty = 0.0
|
||
for p in matched:
|
||
if isinstance(p, dict):
|
||
total_qty += _to_float(p.get("quantity"))
|
||
else:
|
||
total_qty += _to_float(getattr(p, "quantity", None))
|
||
base = matched[0]
|
||
if isinstance(base, dict):
|
||
return SimpleNamespace(
|
||
id=base.get("id"),
|
||
product_id=base.get("productId") or base.get("product_id"),
|
||
package_spec=encode_specs_for_attr(contract_line_package_specs(base)),
|
||
product_name=base.get("productName") or base.get("product_name"),
|
||
model=base.get("model"),
|
||
unit=base.get("unit"),
|
||
quantity=total_qty,
|
||
quantity_mode=base.get("quantityMode") or base.get("quantity_mode"),
|
||
)
|
||
return SimpleNamespace(
|
||
id=getattr(base, "id", None),
|
||
product_id=getattr(base, "product_id", None),
|
||
package_spec=getattr(base, "package_spec", None),
|
||
product_name=getattr(base, "product_name", None),
|
||
model=getattr(base, "model", None),
|
||
unit=getattr(base, "unit", None),
|
||
quantity=total_qty,
|
||
quantity_mode=getattr(base, "quantity_mode", None),
|
||
)
|
||
|
||
|
||
def encode_specs_for_attr(specs: list[str]) -> str:
|
||
from app.utils.crm_package_spec import encode_package_specs
|
||
|
||
return encode_package_specs(specs)
|
||
|
||
|
||
def _bucket_for_plan(*, archive_status: Optional[str], follow_status: Optional[str], transport_status: Optional[str]) -> Optional[str]:
|
||
"""返回 'archived' | 'arranged_unarchived' | None(不计入)。"""
|
||
follow = (follow_status or "").strip()
|
||
if follow == "cancelled" or follow in ("wait_notice", "pending_arrange"):
|
||
return None
|
||
archive = (archive_status or "").strip() or "unarchived"
|
||
if archive == "archived":
|
||
return "archived"
|
||
# 已安排未归档(含 archive draft 暂存)
|
||
transport = (transport_status or "").strip()
|
||
if transport == "arranged" or follow in ("arranged", "completed"):
|
||
return "arranged_unarchived"
|
||
return None
|
||
|
||
|
||
def merge_product_qty_maps(
|
||
*,
|
||
archived: Dict[ProductPackageKey, float],
|
||
arranged_unarchived: Dict[ProductPackageKey, float],
|
||
) -> Dict[ProductPackageKey, Dict[str, float]]:
|
||
"""(product_id, package_spec) -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}"""
|
||
keys = set(archived) | set(arranged_unarchived)
|
||
out: Dict[ProductPackageKey, Dict[str, float]] = {}
|
||
for key in keys:
|
||
a = round(float(archived.get(key, 0.0)), 3)
|
||
u = round(float(arranged_unarchived.get(key, 0.0)), 3)
|
||
out[key] = {
|
||
"archivedQty": a,
|
||
"arrangedUnarchivedQty": u,
|
||
"arrangedTotalQty": round(a + u, 3),
|
||
}
|
||
return out
|
||
|
||
|
||
# plan:计划数量;archive_actual:装货 inbound / 送货 outbound,null 回退 plan_qty
|
||
QTY_MODE_PLAN = "plan"
|
||
QTY_MODE_ARCHIVE_ACTUAL = "archive_actual"
|
||
|
||
|
||
def _resolve_stop_product_qty(
|
||
row: Any,
|
||
*,
|
||
stop_kind: str,
|
||
qty_mode: str,
|
||
) -> float:
|
||
"""按模式解析段产品计入量。"""
|
||
if qty_mode == QTY_MODE_ARCHIVE_ACTUAL:
|
||
attr = "inbound_qty" if stop_kind == "load" else "outbound_qty"
|
||
raw = getattr(row, attr, None)
|
||
if raw is not None:
|
||
return _to_float(raw)
|
||
return _to_float(getattr(row, "plan_qty", None))
|
||
return _to_float(getattr(row, "plan_qty", None))
|
||
|
||
|
||
def accumulate_stop_products(
|
||
target: DefaultDict[ProductPackageKey, float],
|
||
products: Iterable[Any],
|
||
*,
|
||
stop_kind: str = "load",
|
||
qty_mode: str = QTY_MODE_PLAN,
|
||
) -> None:
|
||
for row in products or []:
|
||
qty = _resolve_stop_product_qty(row, stop_kind=stop_kind, qty_mode=qty_mode)
|
||
if not qty:
|
||
continue
|
||
line_key = line_occupancy_key(
|
||
getattr(row, "contract_product_id", None)
|
||
if not isinstance(row, dict)
|
||
else row.get("contractProductId") or row.get("contract_product_id")
|
||
)
|
||
key = product_package_key(
|
||
getattr(row, "product_id", None)
|
||
if not isinstance(row, dict)
|
||
else row.get("productId") or row.get("product_id"),
|
||
getattr(row, "package_spec", None)
|
||
if not isinstance(row, dict)
|
||
else row.get("packageSpec") or row.get("package_spec"),
|
||
)
|
||
display_key = (
|
||
package_display_key(line_key[1], key[1] if key else "") if line_key else None
|
||
)
|
||
if line_key:
|
||
target[line_key] += qty
|
||
if display_key:
|
||
target[display_key] += qty
|
||
continue
|
||
if not key:
|
||
continue
|
||
target[key] += qty
|
||
|
||
|
||
def resolve_linked_plan_scopes(
|
||
link_rows: Iterable[PlPlanContract],
|
||
legacy_plan_ids: Iterable[str],
|
||
) -> Dict[str, Dict[str, Any]]:
|
||
"""
|
||
plan_id -> {
|
||
load_ids: set[str] | None, # None = 该侧全部装货段
|
||
delivery_ids: set[str] | None,
|
||
use_all_stops: bool, # 遗留主表合同:优先送货段,否则装货段,各产品只计一次
|
||
}
|
||
"""
|
||
scopes: Dict[str, Dict[str, Any]] = {}
|
||
|
||
def _ensure(plan_id: str) -> Dict[str, Any]:
|
||
if plan_id not in scopes:
|
||
scopes[plan_id] = {
|
||
"load_ids": set(),
|
||
"delivery_ids": set(),
|
||
"use_all_stops": False,
|
||
"has_explicit": False,
|
||
}
|
||
return scopes[plan_id]
|
||
|
||
for row in link_rows or []:
|
||
plan_id = str(getattr(row, "plan_id", None) or "").strip()
|
||
if not plan_id:
|
||
continue
|
||
scope = _ensure(plan_id)
|
||
side = (getattr(row, "side", None) or "").strip()
|
||
stop_id = str(getattr(row, "stop_id", None) or "").strip()
|
||
scope["has_explicit"] = True
|
||
if side in (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER):
|
||
if stop_id:
|
||
scope["load_ids"].add(stop_id)
|
||
else:
|
||
scope["load_ids"] = None # type: ignore[assignment]
|
||
elif side in (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER):
|
||
if stop_id:
|
||
scope["delivery_ids"].add(stop_id)
|
||
else:
|
||
scope["delivery_ids"] = None # type: ignore[assignment]
|
||
elif stop_id:
|
||
# 未知 side 但有 stop:两边都尝试匹配时由调用方按 id 过滤
|
||
scope["load_ids"].add(stop_id)
|
||
scope["delivery_ids"].add(stop_id)
|
||
|
||
for plan_id in legacy_plan_ids or []:
|
||
pid = str(plan_id or "").strip()
|
||
if not pid:
|
||
continue
|
||
scope = _ensure(pid)
|
||
if not scope["has_explicit"]:
|
||
scope["use_all_stops"] = True
|
||
|
||
return scopes
|
||
|
||
|
||
def sum_qtys_for_scope(
|
||
*,
|
||
load_products_by_load: Dict[str, list],
|
||
delivery_products_by_delivery: Dict[str, list],
|
||
all_load_ids: Set[str],
|
||
all_delivery_ids: Set[str],
|
||
scope: Dict[str, Any],
|
||
qty_mode: str = QTY_MODE_PLAN,
|
||
) -> Dict[ProductPackageKey, float]:
|
||
acc: DefaultDict[ProductPackageKey, float] = defaultdict(float)
|
||
|
||
if scope.get("use_all_stops"):
|
||
# 遗留:优先送货段数量;无送货则用装货,避免装+送双计
|
||
if all_delivery_ids:
|
||
for did in all_delivery_ids:
|
||
accumulate_stop_products(
|
||
acc,
|
||
delivery_products_by_delivery.get(did, []),
|
||
stop_kind="delivery",
|
||
qty_mode=qty_mode,
|
||
)
|
||
else:
|
||
for lid in all_load_ids:
|
||
accumulate_stop_products(
|
||
acc,
|
||
load_products_by_load.get(lid, []),
|
||
stop_kind="load",
|
||
qty_mode=qty_mode,
|
||
)
|
||
return dict(acc)
|
||
|
||
load_ids = scope.get("load_ids")
|
||
delivery_ids = scope.get("delivery_ids")
|
||
|
||
if load_ids is None:
|
||
for lid in all_load_ids:
|
||
accumulate_stop_products(
|
||
acc,
|
||
load_products_by_load.get(lid, []),
|
||
stop_kind="load",
|
||
qty_mode=qty_mode,
|
||
)
|
||
elif load_ids:
|
||
for lid in load_ids:
|
||
accumulate_stop_products(
|
||
acc,
|
||
load_products_by_load.get(lid, []),
|
||
stop_kind="load",
|
||
qty_mode=qty_mode,
|
||
)
|
||
|
||
if delivery_ids is None:
|
||
for did in all_delivery_ids:
|
||
accumulate_stop_products(
|
||
acc,
|
||
delivery_products_by_delivery.get(did, []),
|
||
stop_kind="delivery",
|
||
qty_mode=qty_mode,
|
||
)
|
||
elif delivery_ids:
|
||
for did in delivery_ids:
|
||
accumulate_stop_products(
|
||
acc,
|
||
delivery_products_by_delivery.get(did, []),
|
||
stop_kind="delivery",
|
||
qty_mode=qty_mode,
|
||
)
|
||
|
||
return dict(acc)
|
||
|
||
|
||
def aggregate_contract_product_plan_qtys(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
contract_id: str,
|
||
) -> Dict[ProductPackageKey, Dict[str, float]]:
|
||
"""返回 (productId, packageSpec) -> 三数量字段。"""
|
||
cid = (contract_id or "").strip()
|
||
if not cid:
|
||
return {}
|
||
|
||
link_rows = (
|
||
db.query(PlPlanContract)
|
||
.filter(
|
||
PlPlanContract.tenant_no == tenant_no,
|
||
PlPlanContract.del_flag == 0,
|
||
PlPlanContract.contract_id == cid,
|
||
)
|
||
.all()
|
||
)
|
||
link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
|
||
|
||
legacy_plans = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.contract_id == cid,
|
||
)
|
||
.all()
|
||
)
|
||
legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
|
||
|
||
scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
|
||
plan_ids = list(scopes.keys())
|
||
if not plan_ids:
|
||
return {}
|
||
|
||
plans = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.id.in_(plan_ids),
|
||
PlPlan.follow_status != "cancelled",
|
||
)
|
||
.all()
|
||
)
|
||
plan_by_id = {p.id: p for p in plans}
|
||
|
||
loads = (
|
||
db.query(PlPlanLoad)
|
||
.filter(
|
||
PlPlanLoad.tenant_no == tenant_no,
|
||
PlPlanLoad.del_flag == 0,
|
||
PlPlanLoad.plan_id.in_(plan_ids),
|
||
)
|
||
.all()
|
||
)
|
||
deliveries = (
|
||
db.query(PlPlanDelivery)
|
||
.filter(
|
||
PlPlanDelivery.tenant_no == tenant_no,
|
||
PlPlanDelivery.del_flag == 0,
|
||
PlPlanDelivery.plan_id.in_(plan_ids),
|
||
)
|
||
.all()
|
||
)
|
||
load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
|
||
delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
|
||
for row in loads:
|
||
load_ids_by_plan[row.plan_id].add(row.id)
|
||
for row in deliveries:
|
||
delivery_ids_by_plan[row.plan_id].add(row.id)
|
||
|
||
all_load_ids = {row.id for row in loads}
|
||
all_delivery_ids = {row.id for row in deliveries}
|
||
|
||
load_products_by_load: DefaultDict[str, list] = defaultdict(list)
|
||
if all_load_ids:
|
||
for prod in (
|
||
db.query(PlPlanLoadProduct)
|
||
.filter(
|
||
PlPlanLoadProduct.tenant_no == tenant_no,
|
||
PlPlanLoadProduct.del_flag == 0,
|
||
PlPlanLoadProduct.load_id.in_(all_load_ids),
|
||
)
|
||
.all()
|
||
):
|
||
load_products_by_load[prod.load_id].append(prod)
|
||
|
||
delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
|
||
if all_delivery_ids:
|
||
for prod in (
|
||
db.query(PlPlanDeliveryProduct)
|
||
.filter(
|
||
PlPlanDeliveryProduct.tenant_no == tenant_no,
|
||
PlPlanDeliveryProduct.del_flag == 0,
|
||
PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
|
||
)
|
||
.all()
|
||
):
|
||
delivery_products_by_delivery[prod.delivery_id].append(prod)
|
||
|
||
archived: DefaultDict[ProductPackageKey, float] = defaultdict(float)
|
||
arranged_unarchived: DefaultDict[ProductPackageKey, float] = defaultdict(float)
|
||
|
||
for plan_id, scope in scopes.items():
|
||
plan = plan_by_id.get(plan_id)
|
||
if not plan:
|
||
continue
|
||
bucket = _bucket_for_plan(
|
||
archive_status=plan.archive_status,
|
||
follow_status=plan.follow_status,
|
||
transport_status=plan.transport_status,
|
||
)
|
||
if not bucket:
|
||
continue
|
||
qty_mode = (
|
||
QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
|
||
)
|
||
qtys = sum_qtys_for_scope(
|
||
load_products_by_load=load_products_by_load,
|
||
delivery_products_by_delivery=delivery_products_by_delivery,
|
||
all_load_ids=load_ids_by_plan.get(plan_id, set()),
|
||
all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()),
|
||
scope=scope,
|
||
qty_mode=qty_mode,
|
||
)
|
||
target = archived if bucket == "archived" else arranged_unarchived
|
||
for pid, qty in qtys.items():
|
||
target[pid] += qty
|
||
|
||
return merge_product_qty_maps(archived=dict(archived), arranged_unarchived=dict(arranged_unarchived))
|
||
|
||
|
||
def attach_plan_qty_fields_to_products(
|
||
products: list,
|
||
qty_by_product_id: Dict[ProductPackageKey, Dict[str, float]],
|
||
) -> None:
|
||
zeros = {
|
||
"archivedQty": 0.0,
|
||
"arrangedUnarchivedQty": 0.0,
|
||
"arrangedTotalQty": 0.0,
|
||
}
|
||
for item in products or []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
specs = contract_line_package_specs(item)
|
||
item["packageSpecs"] = specs
|
||
if specs:
|
||
item["packageSpec"] = specs[0]
|
||
elif not item.get("packageSpec"):
|
||
item["packageSpec"] = ""
|
||
keys = pool_keys_for_contract_line(item)
|
||
cpid = contract_product_id_of(item)
|
||
breakdown: list[Dict[str, Any]] = []
|
||
for key in keys:
|
||
first = match_first_contract_product_for_key(products, key) if products else item
|
||
include_pool = first is not None and (
|
||
not cpid or contract_product_id_of(first) == cpid
|
||
)
|
||
stats = _stats_for_package_breakdown(
|
||
qty_by_product_id,
|
||
key,
|
||
zeros,
|
||
contract_product_id=cpid,
|
||
include_pool_key=include_pool,
|
||
)
|
||
breakdown.append(
|
||
{
|
||
"packageSpec": key[1] if key else "",
|
||
"archivedQty": stats["archivedQty"],
|
||
"arrangedUnarchivedQty": stats["arrangedUnarchivedQty"],
|
||
"arrangedTotalQty": stats["arrangedTotalQty"],
|
||
}
|
||
)
|
||
occ_keys = occupancy_keys_for_contract_line(item, products)
|
||
line_stats = (
|
||
sum_qty_stats_for_keys(qty_by_product_id, occ_keys) if occ_keys else dict(zeros)
|
||
)
|
||
item["archivedQty"] = line_stats["archivedQty"]
|
||
item["arrangedUnarchivedQty"] = line_stats["arrangedUnarchivedQty"]
|
||
item["arrangedTotalQty"] = line_stats["arrangedTotalQty"]
|
||
item["packageQtyBreakdown"] = breakdown
|
||
line_qty = _to_float(item.get("quantity"))
|
||
if line_qty > 0:
|
||
item["remainingQty"] = round(line_qty - line_stats["arrangedTotalQty"], 3)
|
||
else:
|
||
item["remainingQty"] = None
|
||
|
||
|
||
def plan_ids_linked_to_contract(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
contract_id: str,
|
||
) -> Set[str]:
|
||
cid = (contract_id or "").strip()
|
||
if not cid:
|
||
return set()
|
||
ids: Set[str] = set()
|
||
for (plan_id,) in (
|
||
db.query(PlPlanContract.plan_id)
|
||
.filter(
|
||
PlPlanContract.tenant_no == tenant_no,
|
||
PlPlanContract.del_flag == 0,
|
||
PlPlanContract.contract_id == cid,
|
||
)
|
||
.distinct()
|
||
.all()
|
||
):
|
||
if plan_id:
|
||
ids.add(str(plan_id))
|
||
for (plan_id,) in (
|
||
db.query(PlPlan.id)
|
||
.filter(
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.contract_id == cid,
|
||
)
|
||
.all()
|
||
):
|
||
if plan_id:
|
||
ids.add(str(plan_id))
|
||
return ids
|
||
|
||
|
||
def contract_ids_linked_to_plan(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan: PlPlan,
|
||
) -> Set[str]:
|
||
ids: Set[str] = set()
|
||
if plan.contract_id:
|
||
ids.add(str(plan.contract_id).strip())
|
||
for (cid,) in (
|
||
db.query(PlPlanContract.contract_id)
|
||
.filter(
|
||
PlPlanContract.tenant_no == tenant_no,
|
||
PlPlanContract.del_flag == 0,
|
||
PlPlanContract.plan_id == plan.id,
|
||
)
|
||
.distinct()
|
||
.all()
|
||
):
|
||
if cid:
|
||
ids.add(str(cid).strip())
|
||
return {x for x in ids if x}
|
||
|
||
|
||
def auto_start_execute_approved_contracts(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan: PlPlan,
|
||
user_id: Optional[str] = None,
|
||
) -> list[str]:
|
||
"""将关联且状态为 approved 的合同置为 executing(不 commit)。返回已推进的合同 id。"""
|
||
from datetime import datetime
|
||
|
||
from app.models.crm_models import CrmContract
|
||
|
||
started: list[str] = []
|
||
for cid in contract_ids_linked_to_plan(db, tenant_no=tenant_no, plan=plan):
|
||
row = (
|
||
db.query(CrmContract)
|
||
.filter(
|
||
CrmContract.id == cid,
|
||
CrmContract.tenant_no == tenant_no,
|
||
CrmContract.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not row or row.status != "approved":
|
||
continue
|
||
row.status = "executing"
|
||
row.update_by = user_id
|
||
row.update_time = datetime.now()
|
||
started.append(cid)
|
||
return started
|
||
|
||
|
||
# =============================================================================
|
||
# 安排运输:合同数量硬拦 / 成功后提醒
|
||
# =============================================================================
|
||
|
||
FRAMEWORK_CONTRACT_TYPES = frozenset({"framework", "framework_sale", "framework_purchase"})
|
||
|
||
|
||
def effective_arranged_total(
|
||
*,
|
||
arranged_total: float,
|
||
old_plan_qty: float,
|
||
new_plan_qty: float,
|
||
) -> float:
|
||
"""已安排合计 − 本计划旧量 + 本计划新量。"""
|
||
return round(
|
||
_to_float(arranged_total) - _to_float(old_plan_qty) + _to_float(new_plan_qty),
|
||
3,
|
||
)
|
||
|
||
|
||
def contract_product_has_fixed_qty(
|
||
*,
|
||
contract_type: Optional[str],
|
||
quantity: Any,
|
||
quantity_mode: Optional[str],
|
||
) -> bool:
|
||
"""合同产品行是否按固定数量做超量硬拦。"""
|
||
ctype = (contract_type or "").strip()
|
||
mode = (quantity_mode or "").strip() or "fixed"
|
||
if ctype in FRAMEWORK_CONTRACT_TYPES and mode == "customer_demand":
|
||
return False
|
||
qty = _to_float(quantity)
|
||
return qty > 0
|
||
|
||
|
||
def evaluate_product_qty_for_arrange(
|
||
*,
|
||
has_fixed_qty: bool,
|
||
contract_qty: Optional[float],
|
||
arranged_total: float,
|
||
old_plan_qty: float,
|
||
new_plan_qty: float,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
返回:
|
||
- None: 跳过(本次无量)
|
||
- kind=violation | remaining | framework_cumulative
|
||
"""
|
||
new_qty = _to_float(new_plan_qty)
|
||
if new_qty <= 0:
|
||
return None
|
||
|
||
old_qty = _to_float(old_plan_qty)
|
||
arranged = _to_float(arranged_total)
|
||
effective = effective_arranged_total(
|
||
arranged_total=arranged,
|
||
old_plan_qty=old_qty,
|
||
new_plan_qty=new_qty,
|
||
)
|
||
|
||
if not has_fixed_qty:
|
||
return {
|
||
"kind": "framework_cumulative",
|
||
"arrangedTotalQty": effective,
|
||
"thisPlanQty": round(new_qty, 3),
|
||
"effectiveTotal": effective,
|
||
}
|
||
|
||
cq = _to_float(contract_qty)
|
||
overflow = round(effective - cq, 3)
|
||
base = {
|
||
"contractQty": round(cq, 3),
|
||
"arrangedTotalQty": effective,
|
||
"thisPlanQty": round(new_qty, 3),
|
||
"oldPlanQty": round(old_qty, 3),
|
||
"newPlanQty": round(new_qty, 3),
|
||
"effectiveTotal": effective,
|
||
}
|
||
if overflow > 0:
|
||
return {**base, "kind": "violation", "overflowQty": overflow}
|
||
return {
|
||
**base,
|
||
"kind": "remaining",
|
||
"remainingQty": round(cq - effective, 3),
|
||
}
|
||
|
||
|
||
CONTRACT_QTY_VIOLATION_CODE = "CONTRACT_QTY_OVERFLOW"
|
||
ARCHIVE_LINE_QTY_OVERAGE_CODE = "CONTRACT_LINE_QTY_OVERAGE"
|
||
|
||
|
||
def _stop_kind_from_scope(scope: Dict[str, Any]) -> str:
|
||
load_ids = scope.get("load_ids")
|
||
delivery_ids = scope.get("delivery_ids")
|
||
has_load = load_ids is None or bool(load_ids)
|
||
has_delivery = delivery_ids is None or bool(delivery_ids)
|
||
if scope.get("use_all_stops"):
|
||
return "delivery"
|
||
if has_load and not has_delivery:
|
||
return "load"
|
||
if has_delivery and not has_load:
|
||
return "delivery"
|
||
if has_load:
|
||
return "load"
|
||
return "delivery"
|
||
|
||
|
||
def _plan_scope_for_contract(
|
||
*,
|
||
plan_id: str,
|
||
contract_id: str,
|
||
link_rows: list[PlPlanContract],
|
||
legacy_contract_id: Optional[str],
|
||
) -> Dict[str, Any]:
|
||
cid = (contract_id or "").strip()
|
||
links = [r for r in link_rows if str(r.contract_id or "").strip() == cid]
|
||
legacy_ids: list[str] = []
|
||
if not links and legacy_contract_id and str(legacy_contract_id).strip() == cid:
|
||
legacy_ids = [plan_id]
|
||
scopes = resolve_linked_plan_scopes(links, legacy_ids)
|
||
return scopes.get(plan_id) or {
|
||
"load_ids": set(),
|
||
"delivery_ids": set(),
|
||
"use_all_stops": False,
|
||
"has_explicit": False,
|
||
}
|
||
|
||
|
||
def _load_plan_stop_product_maps(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan_id: str,
|
||
) -> tuple[Set[str], Set[str], Dict[str, list], Dict[str, list]]:
|
||
loads = (
|
||
db.query(PlPlanLoad)
|
||
.filter(
|
||
PlPlanLoad.tenant_no == tenant_no,
|
||
PlPlanLoad.del_flag == 0,
|
||
PlPlanLoad.plan_id == plan_id,
|
||
)
|
||
.all()
|
||
)
|
||
deliveries = (
|
||
db.query(PlPlanDelivery)
|
||
.filter(
|
||
PlPlanDelivery.tenant_no == tenant_no,
|
||
PlPlanDelivery.del_flag == 0,
|
||
PlPlanDelivery.plan_id == plan_id,
|
||
)
|
||
.all()
|
||
)
|
||
load_ids = {row.id for row in loads}
|
||
delivery_ids = {row.id for row in deliveries}
|
||
load_products_by_load: DefaultDict[str, list] = defaultdict(list)
|
||
if load_ids:
|
||
for prod in (
|
||
db.query(PlPlanLoadProduct)
|
||
.filter(
|
||
PlPlanLoadProduct.tenant_no == tenant_no,
|
||
PlPlanLoadProduct.del_flag == 0,
|
||
PlPlanLoadProduct.load_id.in_(load_ids),
|
||
)
|
||
.all()
|
||
):
|
||
load_products_by_load[prod.load_id].append(prod)
|
||
delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
|
||
if delivery_ids:
|
||
for prod in (
|
||
db.query(PlPlanDeliveryProduct)
|
||
.filter(
|
||
PlPlanDeliveryProduct.tenant_no == tenant_no,
|
||
PlPlanDeliveryProduct.del_flag == 0,
|
||
PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
|
||
)
|
||
.all()
|
||
):
|
||
delivery_products_by_delivery[prod.delivery_id].append(prod)
|
||
return load_ids, delivery_ids, dict(load_products_by_load), dict(delivery_products_by_delivery)
|
||
|
||
|
||
def collect_plan_contract_qty_checks(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan_id: str,
|
||
) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
|
||
"""
|
||
返回 (violations, reminders)。
|
||
在写运输前调用;reminders 可缓存到成功响应,勿在状态变更后再算。
|
||
"""
|
||
from app.models.crm_models import CrmContract, CrmContractProduct
|
||
|
||
plan = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.id == plan_id,
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not plan:
|
||
return [], []
|
||
|
||
link_rows = (
|
||
db.query(PlPlanContract)
|
||
.filter(
|
||
PlPlanContract.tenant_no == tenant_no,
|
||
PlPlanContract.del_flag == 0,
|
||
PlPlanContract.plan_id == plan_id,
|
||
)
|
||
.all()
|
||
)
|
||
contract_ids: Set[str] = set()
|
||
for row in link_rows:
|
||
cid = str(row.contract_id or "").strip()
|
||
if cid:
|
||
contract_ids.add(cid)
|
||
legacy_cid = str(plan.contract_id or "").strip()
|
||
if legacy_cid:
|
||
contract_ids.add(legacy_cid)
|
||
if not contract_ids:
|
||
return [], []
|
||
|
||
load_ids, delivery_ids, load_products_by_load, delivery_products_by_delivery = (
|
||
_load_plan_stop_product_maps(db, tenant_no=tenant_no, plan_id=plan_id)
|
||
)
|
||
in_bucket = (
|
||
_bucket_for_plan(
|
||
archive_status=plan.archive_status,
|
||
follow_status=plan.follow_status,
|
||
transport_status=plan.transport_status,
|
||
)
|
||
is not None
|
||
)
|
||
|
||
violations: list[Dict[str, Any]] = []
|
||
reminders: list[Dict[str, Any]] = []
|
||
|
||
for cid in sorted(contract_ids):
|
||
contract = (
|
||
db.query(CrmContract)
|
||
.filter(
|
||
CrmContract.id == cid,
|
||
CrmContract.tenant_no == tenant_no,
|
||
CrmContract.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not contract:
|
||
continue
|
||
|
||
scope = _plan_scope_for_contract(
|
||
plan_id=plan_id,
|
||
contract_id=cid,
|
||
link_rows=link_rows,
|
||
legacy_contract_id=legacy_cid,
|
||
)
|
||
|
||
plan_qtys = sum_qtys_for_scope(
|
||
load_products_by_load=load_products_by_load,
|
||
delivery_products_by_delivery=delivery_products_by_delivery,
|
||
all_load_ids=load_ids,
|
||
all_delivery_ids=delivery_ids,
|
||
scope=scope,
|
||
)
|
||
if not plan_qtys:
|
||
continue
|
||
|
||
qty_map = aggregate_contract_product_plan_qtys(
|
||
db, tenant_no=tenant_no, contract_id=cid
|
||
)
|
||
products = (
|
||
db.query(CrmContractProduct)
|
||
.filter(
|
||
CrmContractProduct.contract_id == cid,
|
||
CrmContractProduct.tenant_no == tenant_no,
|
||
CrmContractProduct.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
stop_kind = _stop_kind_from_scope(scope)
|
||
contract_type = (contract.contract_type or "").strip()
|
||
contract_no = contract.contract_no or ""
|
||
customer_name = (contract.customer_name or "").strip()
|
||
|
||
products_by_id = {
|
||
str(getattr(p, "id", None) or "").strip(): p
|
||
for p in products
|
||
if str(getattr(p, "id", None) or "").strip()
|
||
}
|
||
line_groups: Dict[str, Dict[str, Any]] = {}
|
||
for key, new_qty_raw in plan_qtys.items():
|
||
new_qty = _to_float(new_qty_raw)
|
||
if new_qty <= 0:
|
||
continue
|
||
if not isinstance(key, tuple) or len(key) != 2:
|
||
continue
|
||
if is_line_occupancy_key(key):
|
||
cp = products_by_id.get(str(key[1]))
|
||
if not cp:
|
||
continue
|
||
pid = str(getattr(cp, "product_id", None) or "").strip()
|
||
specs = contract_line_package_specs(cp)
|
||
pkg = specs[0] if specs else ""
|
||
elif key[0] == PKG_DISPLAY_PREFIX:
|
||
continue
|
||
else:
|
||
pid, pkg = key[0], key[1]
|
||
cp = match_first_contract_product_for_key(products, key)
|
||
if not cp:
|
||
continue
|
||
|
||
line_id = str(getattr(cp, "id", None) or "").strip()
|
||
if not line_id:
|
||
specs = contract_line_package_specs(cp)
|
||
line_id = f"{pid}\0{'|'.join(specs)}"
|
||
group = line_groups.get(line_id)
|
||
if not group:
|
||
group = {
|
||
"cp": cp,
|
||
"pid": pid,
|
||
"pool_keys": occupancy_keys_for_contract_line(cp, products),
|
||
"this_plan_qty": 0.0,
|
||
"packages": [],
|
||
}
|
||
line_groups[line_id] = group
|
||
group["this_plan_qty"] += new_qty
|
||
if pkg:
|
||
group["packages"].append(pkg)
|
||
|
||
for group in line_groups.values():
|
||
cp = group["cp"]
|
||
pid = group["pid"]
|
||
new_qty = group["this_plan_qty"]
|
||
pkg_label = "、".join(dict.fromkeys(group["packages"])) or None
|
||
pool_stats = sum_qty_stats_for_keys(qty_map, group["pool_keys"])
|
||
arranged_total = pool_stats["arrangedTotalQty"]
|
||
if in_bucket:
|
||
old_qty = new_qty
|
||
# arranged 已含当前库内量;effective = arranged - old + new = arranged
|
||
else:
|
||
old_qty = 0.0
|
||
|
||
has_fixed = contract_product_has_fixed_qty(
|
||
contract_type=contract_type,
|
||
quantity=cp.quantity,
|
||
quantity_mode=cp.quantity_mode,
|
||
)
|
||
evaluated = evaluate_product_qty_for_arrange(
|
||
has_fixed_qty=has_fixed,
|
||
contract_qty=_to_float(cp.quantity) if has_fixed else None,
|
||
arranged_total=arranged_total,
|
||
old_plan_qty=old_qty,
|
||
new_plan_qty=new_qty,
|
||
)
|
||
if not evaluated:
|
||
continue
|
||
|
||
unit_price = getattr(cp, "price", None)
|
||
item = {
|
||
"contractId": cid,
|
||
"contractNo": contract_no,
|
||
"contractType": contract_type,
|
||
"customerName": customer_name,
|
||
"stopKind": stop_kind,
|
||
"contractProductId": str(getattr(cp, "id", None) or "").strip() or None,
|
||
"productId": pid,
|
||
"packageSpec": pkg_label,
|
||
"packageSpecs": contract_line_package_specs(cp),
|
||
"productName": cp.product_name or "",
|
||
"model": (cp.model or "").strip() or None,
|
||
"unit": (cp.unit or "").strip() or None,
|
||
"unitPrice": round(_to_float(unit_price), 2) if unit_price is not None else None,
|
||
"thisPlanQty": evaluated.get("thisPlanQty", round(new_qty, 3)),
|
||
"arrangedTotalQty": evaluated["arrangedTotalQty"],
|
||
}
|
||
kind = evaluated["kind"]
|
||
if kind == "violation":
|
||
violations.append(
|
||
{
|
||
**item,
|
||
"contractQty": evaluated["contractQty"],
|
||
"oldPlanQty": evaluated["oldPlanQty"],
|
||
"newPlanQty": evaluated["newPlanQty"],
|
||
"effectiveTotal": evaluated["effectiveTotal"],
|
||
"overflowQty": evaluated["overflowQty"],
|
||
# 与提醒表同列:剩余可为负,表示超量
|
||
"remainingQty": round(
|
||
evaluated["contractQty"] - evaluated["effectiveTotal"], 3
|
||
),
|
||
}
|
||
)
|
||
elif kind == "remaining":
|
||
reminders.append(
|
||
{
|
||
**item,
|
||
"mode": "remaining",
|
||
"contractQty": evaluated["contractQty"],
|
||
"remainingQty": evaluated["remainingQty"],
|
||
}
|
||
)
|
||
elif kind == "framework_cumulative":
|
||
reminders.append(
|
||
{
|
||
**item,
|
||
"mode": "framework_cumulative",
|
||
}
|
||
)
|
||
|
||
return violations, reminders
|
||
|
||
|
||
def assert_contract_qty_for_arrange(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan_id: str,
|
||
) -> list[Dict[str, Any]]:
|
||
"""超限则 422;通过则返回 reminders(供成功响应复用)。"""
|
||
from fastapi import HTTPException
|
||
|
||
violations, reminders = collect_plan_contract_qty_checks(
|
||
db, tenant_no=tenant_no, plan_id=plan_id
|
||
)
|
||
if violations:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail={
|
||
"code": CONTRACT_QTY_VIOLATION_CODE,
|
||
"message": "合同产品安排数量超过合同数量",
|
||
"items": violations,
|
||
},
|
||
)
|
||
return reminders
|
||
|
||
|
||
def build_contract_qty_reminder_payload(
|
||
reminders: list[Dict[str, Any]],
|
||
) -> Optional[Dict[str, Any]]:
|
||
if not reminders:
|
||
return None
|
||
return {"items": reminders}
|
||
|
||
|
||
def validate_stop_products_package_against_contract(
|
||
*,
|
||
contract_products: Iterable[Any],
|
||
stop_products: Iterable[Any],
|
||
) -> tuple[bool, Optional[str]]:
|
||
"""
|
||
绑合同时:计划段产品包装必填,且必须属于本合同该产品的包装集合。
|
||
返回 (ok, error_message)。
|
||
"""
|
||
allowed_by_pid: DefaultDict[str, Set[str]] = defaultdict(set)
|
||
contract_pids: Set[str] = set()
|
||
for row in contract_products or []:
|
||
pid = ""
|
||
if isinstance(row, dict):
|
||
pid = str(row.get("productId") or row.get("product_id") or "").strip()
|
||
else:
|
||
pid = str(getattr(row, "product_id", None) or "").strip()
|
||
if not pid:
|
||
continue
|
||
contract_pids.add(pid)
|
||
for pkg in contract_line_package_specs(row):
|
||
if pkg:
|
||
allowed_by_pid[pid].add(pkg)
|
||
|
||
for row in stop_products or []:
|
||
pid = str(getattr(row, "product_id", None) or "").strip()
|
||
if not pid or pid not in contract_pids:
|
||
continue
|
||
allowed = allowed_by_pid.get(pid) or set()
|
||
if not allowed:
|
||
continue
|
||
pkg = normalize_package_spec(getattr(row, "package_spec", None))
|
||
name = (
|
||
str(getattr(row, "product_name", None) or "").strip()
|
||
or pid
|
||
)
|
||
if not pkg:
|
||
return False, f"请选择包装规格:{name}"
|
||
if pkg not in allowed:
|
||
return False, f"包装规格不在合同明细中:{name}({pkg})"
|
||
return True, None
|
||
|
||
|
||
def get_contract_product_arrange_qty_detail(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
contract_id: str,
|
||
product_id: str = "",
|
||
package_spec: Optional[str] = None,
|
||
exclude_plan_id: Optional[str] = None,
|
||
contract_product_id: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
表单「可安排数量」与问号弹窗数据。
|
||
arrangedQty / availableQty 均不含 exclude_plan_id(若该计划已在已安排桶中)。
|
||
"""
|
||
from app.models.crm_models import CrmContract, CrmContractProduct
|
||
|
||
cid = (contract_id or "").strip()
|
||
cpid = str(contract_product_id or "").strip()
|
||
pid = (product_id or "").strip()
|
||
pkg = normalize_package_spec(package_spec)
|
||
key = product_package_key(pid, pkg) if pid else None
|
||
exclude_id = (exclude_plan_id or "").strip() or None
|
||
empty = {
|
||
"contractId": cid,
|
||
"contractNo": None,
|
||
"customerName": None,
|
||
"contractProductId": cpid or None,
|
||
"productId": pid,
|
||
"packageSpec": pkg or None,
|
||
"packageSpecs": [],
|
||
"packageBreakdown": [],
|
||
"productName": None,
|
||
"hasFixedQty": False,
|
||
"contractQty": None,
|
||
"arrangedQty": 0.0,
|
||
"availableQty": None,
|
||
"plans": [],
|
||
}
|
||
if not cid:
|
||
return empty
|
||
if not cpid and (not pid or not key):
|
||
return empty
|
||
|
||
contract = (
|
||
db.query(CrmContract)
|
||
.filter(
|
||
CrmContract.id == cid,
|
||
CrmContract.tenant_no == tenant_no,
|
||
CrmContract.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not contract:
|
||
return empty
|
||
|
||
all_lines = (
|
||
db.query(CrmContractProduct)
|
||
.filter(
|
||
CrmContractProduct.contract_id == cid,
|
||
CrmContractProduct.tenant_no == tenant_no,
|
||
CrmContractProduct.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
cp = None
|
||
if cpid:
|
||
cp = next((row for row in all_lines if str(row.id) == cpid), None)
|
||
if cp:
|
||
pid = str(cp.product_id or "").strip()
|
||
specs = contract_line_package_specs(cp)
|
||
if not pkg and specs:
|
||
pkg = specs[0]
|
||
key = product_package_key(pid, pkg) if pid else key
|
||
elif key:
|
||
cp = match_first_contract_product_for_key(all_lines, key)
|
||
contract_type = (contract.contract_type or "").strip()
|
||
has_fixed = False
|
||
contract_qty: Optional[float] = None
|
||
package_specs: list[str] = []
|
||
pool_keys: list[ProductPackageKey] = [key] if key else []
|
||
occupancy_keys: list[ProductPackageKey] = list(pool_keys)
|
||
if cp:
|
||
package_specs = contract_line_package_specs(cp)
|
||
pool_keys = pool_keys_for_contract_line(cp) or pool_keys
|
||
occupancy_keys = occupancy_keys_for_contract_line(cp, all_lines) or occupancy_keys
|
||
has_fixed = contract_product_has_fixed_qty(
|
||
contract_type=contract_type,
|
||
quantity=cp.quantity,
|
||
quantity_mode=cp.quantity_mode,
|
||
)
|
||
if has_fixed:
|
||
contract_qty = round(_to_float(cp.quantity), 3)
|
||
|
||
qty_map = aggregate_contract_product_plan_qtys(
|
||
db, tenant_no=tenant_no, contract_id=cid
|
||
)
|
||
arranged_including = sum_qty_stats_for_keys(qty_map, occupancy_keys)["arrangedTotalQty"]
|
||
line_cpid = contract_product_id_of(cp) if cp else cpid
|
||
package_breakdown = []
|
||
for pool_key in pool_keys:
|
||
first = match_first_contract_product_for_key(all_lines, pool_key) if all_lines else cp
|
||
include_pool = first is not None and (
|
||
not line_cpid or contract_product_id_of(first) == line_cpid
|
||
)
|
||
stats = _stats_for_package_breakdown(
|
||
qty_map,
|
||
pool_key,
|
||
{
|
||
"archivedQty": 0.0,
|
||
"arrangedUnarchivedQty": 0.0,
|
||
"arrangedTotalQty": 0.0,
|
||
},
|
||
contract_product_id=line_cpid,
|
||
include_pool_key=include_pool,
|
||
)
|
||
package_breakdown.append(
|
||
{
|
||
"packageSpec": pool_key[1],
|
||
"archivedQty": stats["archivedQty"],
|
||
"arrangedUnarchivedQty": stats["arrangedUnarchivedQty"],
|
||
"arrangedTotalQty": stats["arrangedTotalQty"],
|
||
}
|
||
)
|
||
link_rows = (
|
||
db.query(PlPlanContract)
|
||
.filter(
|
||
PlPlanContract.tenant_no == tenant_no,
|
||
PlPlanContract.del_flag == 0,
|
||
PlPlanContract.contract_id == cid,
|
||
)
|
||
.all()
|
||
)
|
||
link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
|
||
legacy_plans = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.contract_id == cid,
|
||
)
|
||
.all()
|
||
)
|
||
legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
|
||
scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
|
||
plan_ids = list(scopes.keys())
|
||
|
||
exclude_qty = 0.0
|
||
plans_out: list[Dict[str, Any]] = []
|
||
|
||
if plan_ids:
|
||
plans = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.id.in_(plan_ids),
|
||
PlPlan.follow_status != "cancelled",
|
||
)
|
||
.all()
|
||
)
|
||
plan_by_id = {p.id: p for p in plans}
|
||
|
||
loads = (
|
||
db.query(PlPlanLoad)
|
||
.filter(
|
||
PlPlanLoad.tenant_no == tenant_no,
|
||
PlPlanLoad.del_flag == 0,
|
||
PlPlanLoad.plan_id.in_(plan_ids),
|
||
)
|
||
.all()
|
||
)
|
||
deliveries = (
|
||
db.query(PlPlanDelivery)
|
||
.filter(
|
||
PlPlanDelivery.tenant_no == tenant_no,
|
||
PlPlanDelivery.del_flag == 0,
|
||
PlPlanDelivery.plan_id.in_(plan_ids),
|
||
)
|
||
.all()
|
||
)
|
||
load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
|
||
delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
|
||
for row in loads:
|
||
load_ids_by_plan[row.plan_id].add(row.id)
|
||
for row in deliveries:
|
||
delivery_ids_by_plan[row.plan_id].add(row.id)
|
||
|
||
all_load_ids = {row.id for row in loads}
|
||
all_delivery_ids = {row.id for row in deliveries}
|
||
load_products_by_load: DefaultDict[str, list] = defaultdict(list)
|
||
if all_load_ids:
|
||
for prod in (
|
||
db.query(PlPlanLoadProduct)
|
||
.filter(
|
||
PlPlanLoadProduct.tenant_no == tenant_no,
|
||
PlPlanLoadProduct.del_flag == 0,
|
||
PlPlanLoadProduct.load_id.in_(all_load_ids),
|
||
)
|
||
.all()
|
||
):
|
||
load_products_by_load[prod.load_id].append(prod)
|
||
delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
|
||
if all_delivery_ids:
|
||
for prod in (
|
||
db.query(PlPlanDeliveryProduct)
|
||
.filter(
|
||
PlPlanDeliveryProduct.tenant_no == tenant_no,
|
||
PlPlanDeliveryProduct.del_flag == 0,
|
||
PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
|
||
)
|
||
.all()
|
||
):
|
||
delivery_products_by_delivery[prod.delivery_id].append(prod)
|
||
|
||
for plan_id, scope in scopes.items():
|
||
plan = plan_by_id.get(plan_id)
|
||
if not plan:
|
||
continue
|
||
bucket = _bucket_for_plan(
|
||
archive_status=plan.archive_status,
|
||
follow_status=plan.follow_status,
|
||
transport_status=plan.transport_status,
|
||
)
|
||
if not bucket:
|
||
continue
|
||
qty_mode = (
|
||
QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
|
||
)
|
||
qtys = sum_qtys_for_scope(
|
||
load_products_by_load=load_products_by_load,
|
||
delivery_products_by_delivery=delivery_products_by_delivery,
|
||
all_load_ids=load_ids_by_plan.get(plan_id, set()),
|
||
all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()),
|
||
scope=scope,
|
||
qty_mode=qty_mode,
|
||
)
|
||
pq = 0.0
|
||
for occ_key in occupancy_keys:
|
||
pq += _to_float(qtys.get(occ_key))
|
||
if pq <= 0:
|
||
continue
|
||
if exclude_id and str(plan_id) == exclude_id:
|
||
exclude_qty = pq
|
||
continue
|
||
plan_date = getattr(plan, "plan_date", None)
|
||
plans_out.append(
|
||
{
|
||
"planId": plan_id,
|
||
"planNo": plan.plan_no or "",
|
||
"planDate": plan_date.isoformat() if plan_date else None,
|
||
"planQty": round(pq, 3),
|
||
"planType": getattr(plan, "plan_type", None),
|
||
"belongsCompany": getattr(plan, "belongs_company", None),
|
||
"supplierName": getattr(plan, "supplier_name", None),
|
||
"customerName": getattr(plan, "customer_name", None),
|
||
"licensePlate": getattr(plan, "license_plate", None),
|
||
"driverName": getattr(plan, "driver_name", None),
|
||
"followUserName": getattr(plan, "follow_user_name", None),
|
||
"followStatus": plan.follow_status,
|
||
"transportStatus": plan.transport_status,
|
||
"archiveStatus": plan.archive_status,
|
||
"createTime": plan.create_time.isoformat(sep=" ", timespec="seconds")
|
||
if getattr(plan, "create_time", None)
|
||
else None,
|
||
}
|
||
)
|
||
|
||
plans_out.sort(key=lambda x: (x.get("planDate") or "", x.get("planNo") or ""), reverse=True)
|
||
|
||
arranged_qty = round(max(0.0, arranged_including - exclude_qty), 3)
|
||
available_qty = None
|
||
if has_fixed and contract_qty is not None:
|
||
available_qty = round(contract_qty - arranged_qty, 3)
|
||
|
||
return {
|
||
"contractId": cid,
|
||
"contractNo": contract.contract_no or "",
|
||
"customerName": (contract.customer_name or "").strip() or None,
|
||
"contractProductId": str(getattr(cp, "id", None) or "").strip() or cpid or None,
|
||
"productId": pid,
|
||
"packageSpec": pkg or None,
|
||
"packageSpecs": package_specs or ([pkg] if pkg else []),
|
||
"packageBreakdown": package_breakdown,
|
||
"productName": (cp.product_name if cp else None) or None,
|
||
"hasFixedQty": has_fixed,
|
||
"contractQty": contract_qty,
|
||
"arrangedQty": arranged_qty,
|
||
"availableQty": available_qty,
|
||
"plans": plans_out,
|
||
}
|
||
|
||
|
||
def _exclude_plan_qty_for_keys(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
contract_id: str,
|
||
exclude_plan_id: str,
|
||
occupancy_keys: list[ProductPackageKey],
|
||
) -> float:
|
||
"""当前编辑计划在已安排桶中时,从占用中扣回本计划用量(轻量,不拼 plans 列表)。"""
|
||
cid = (contract_id or "").strip()
|
||
exclude_id = (exclude_plan_id or "").strip()
|
||
if not cid or not exclude_id or not occupancy_keys:
|
||
return 0.0
|
||
link_rows = (
|
||
db.query(PlPlanContract)
|
||
.filter(
|
||
PlPlanContract.tenant_no == tenant_no,
|
||
PlPlanContract.del_flag == 0,
|
||
PlPlanContract.contract_id == cid,
|
||
PlPlanContract.plan_id == exclude_id,
|
||
)
|
||
.all()
|
||
)
|
||
legacy = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.id == exclude_id,
|
||
PlPlan.contract_id == cid,
|
||
)
|
||
.first()
|
||
)
|
||
legacy_only = {exclude_id} if legacy and not link_rows else set()
|
||
scopes = resolve_linked_plan_scopes(link_rows, legacy_only)
|
||
scope = scopes.get(exclude_id)
|
||
if not scope:
|
||
return 0.0
|
||
plan = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.id == exclude_id,
|
||
PlPlan.follow_status != "cancelled",
|
||
)
|
||
.first()
|
||
)
|
||
if not plan:
|
||
return 0.0
|
||
bucket = _bucket_for_plan(
|
||
archive_status=plan.archive_status,
|
||
follow_status=plan.follow_status,
|
||
transport_status=plan.transport_status,
|
||
)
|
||
if not bucket:
|
||
return 0.0
|
||
qty_mode = QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
|
||
loads = (
|
||
db.query(PlPlanLoad)
|
||
.filter(
|
||
PlPlanLoad.tenant_no == tenant_no,
|
||
PlPlanLoad.del_flag == 0,
|
||
PlPlanLoad.plan_id == exclude_id,
|
||
)
|
||
.all()
|
||
)
|
||
deliveries = (
|
||
db.query(PlPlanDelivery)
|
||
.filter(
|
||
PlPlanDelivery.tenant_no == tenant_no,
|
||
PlPlanDelivery.del_flag == 0,
|
||
PlPlanDelivery.plan_id == exclude_id,
|
||
)
|
||
.all()
|
||
)
|
||
load_ids = {row.id for row in loads}
|
||
delivery_ids = {row.id for row in deliveries}
|
||
load_products_by_load: DefaultDict[str, list] = defaultdict(list)
|
||
if load_ids:
|
||
for prod in (
|
||
db.query(PlPlanLoadProduct)
|
||
.filter(
|
||
PlPlanLoadProduct.tenant_no == tenant_no,
|
||
PlPlanLoadProduct.del_flag == 0,
|
||
PlPlanLoadProduct.load_id.in_(load_ids),
|
||
)
|
||
.all()
|
||
):
|
||
load_products_by_load[prod.load_id].append(prod)
|
||
delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
|
||
if delivery_ids:
|
||
for prod in (
|
||
db.query(PlPlanDeliveryProduct)
|
||
.filter(
|
||
PlPlanDeliveryProduct.tenant_no == tenant_no,
|
||
PlPlanDeliveryProduct.del_flag == 0,
|
||
PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
|
||
)
|
||
.all()
|
||
):
|
||
delivery_products_by_delivery[prod.delivery_id].append(prod)
|
||
qtys = sum_qtys_for_scope(
|
||
load_products_by_load=load_products_by_load,
|
||
delivery_products_by_delivery=delivery_products_by_delivery,
|
||
all_load_ids=load_ids,
|
||
all_delivery_ids=delivery_ids,
|
||
scope=scope,
|
||
qty_mode=qty_mode,
|
||
)
|
||
total = 0.0
|
||
for occ_key in occupancy_keys:
|
||
total += _to_float(qtys.get(occ_key))
|
||
return total
|
||
|
||
|
||
def batch_contract_product_arrange_qty_summaries(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
items: list[Dict[str, Any]],
|
||
) -> list[Dict[str, Any]]:
|
||
"""
|
||
表单打开时批量取「可安排数量」摘要(不含 plans 明细)。
|
||
同合同只聚合一次用量,供装/送货产品表展示,避免 N 次详情接口。
|
||
"""
|
||
from app.models.crm_models import CrmContract, CrmContractProduct
|
||
|
||
if not items:
|
||
return []
|
||
|
||
by_contract: DefaultDict[str, list[tuple[int, Dict[str, Any]]]] = defaultdict(list)
|
||
for idx, raw in enumerate(items):
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
cid = str(raw.get("contractId") or raw.get("contract_id") or "").strip()
|
||
if not cid:
|
||
continue
|
||
by_contract[cid].append((idx, raw))
|
||
|
||
out: list[Optional[Dict[str, Any]]] = [None] * len(items)
|
||
for cid, indexed in by_contract.items():
|
||
contract = (
|
||
db.query(CrmContract)
|
||
.filter(
|
||
CrmContract.id == cid,
|
||
CrmContract.tenant_no == tenant_no,
|
||
CrmContract.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not contract:
|
||
for idx, raw in indexed:
|
||
out[idx] = {
|
||
"key": str(raw.get("key") or ""),
|
||
"contractId": cid,
|
||
"contractNo": None,
|
||
"customerName": None,
|
||
"contractProductId": str(
|
||
raw.get("contractProductId") or raw.get("contract_product_id") or ""
|
||
).strip()
|
||
or None,
|
||
"productId": str(raw.get("productId") or raw.get("product_id") or "").strip(),
|
||
"packageSpec": normalize_package_spec(
|
||
raw.get("packageSpec") or raw.get("package_spec")
|
||
)
|
||
or None,
|
||
"packageSpecs": [],
|
||
"packageBreakdown": [],
|
||
"productName": None,
|
||
"hasFixedQty": False,
|
||
"contractQty": None,
|
||
"arrangedQty": 0.0,
|
||
"availableQty": None,
|
||
"plans": [],
|
||
}
|
||
continue
|
||
|
||
all_lines = (
|
||
db.query(CrmContractProduct)
|
||
.filter(
|
||
CrmContractProduct.contract_id == cid,
|
||
CrmContractProduct.tenant_no == tenant_no,
|
||
CrmContractProduct.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
qty_map = aggregate_contract_product_plan_qtys(
|
||
db, tenant_no=tenant_no, contract_id=cid
|
||
)
|
||
contract_type = (contract.contract_type or "").strip()
|
||
exclude_cache: Dict[str, Dict[Tuple[ProductPackageKey, ...], float]] = {}
|
||
|
||
for idx, raw in indexed:
|
||
cpid = str(
|
||
raw.get("contractProductId") or raw.get("contract_product_id") or ""
|
||
).strip()
|
||
pid = str(raw.get("productId") or raw.get("product_id") or "").strip()
|
||
pkg = normalize_package_spec(raw.get("packageSpec") or raw.get("package_spec"))
|
||
key = product_package_key(pid, pkg) if pid else None
|
||
exclude_id = str(
|
||
raw.get("excludePlanId") or raw.get("exclude_plan_id") or ""
|
||
).strip()
|
||
client_key = str(raw.get("key") or "")
|
||
|
||
cp = None
|
||
if cpid:
|
||
cp = next((row for row in all_lines if str(row.id) == cpid), None)
|
||
if cp:
|
||
pid = str(cp.product_id or "").strip()
|
||
specs = contract_line_package_specs(cp)
|
||
if not pkg and specs:
|
||
pkg = specs[0]
|
||
key = product_package_key(pid, pkg) if pid else key
|
||
elif key:
|
||
cp = match_first_contract_product_for_key(all_lines, key)
|
||
|
||
has_fixed = False
|
||
contract_qty: Optional[float] = None
|
||
package_specs: list[str] = []
|
||
pool_keys: list[ProductPackageKey] = [key] if key else []
|
||
occupancy_keys: list[ProductPackageKey] = list(pool_keys)
|
||
if cp:
|
||
package_specs = contract_line_package_specs(cp)
|
||
pool_keys = pool_keys_for_contract_line(cp) or pool_keys
|
||
occupancy_keys = (
|
||
occupancy_keys_for_contract_line(cp, all_lines) or occupancy_keys
|
||
)
|
||
has_fixed = contract_product_has_fixed_qty(
|
||
contract_type=contract_type,
|
||
quantity=cp.quantity,
|
||
quantity_mode=cp.quantity_mode,
|
||
)
|
||
if has_fixed:
|
||
contract_qty = round(_to_float(cp.quantity), 3)
|
||
|
||
arranged_including = sum_qty_stats_for_keys(qty_map, occupancy_keys)[
|
||
"arrangedTotalQty"
|
||
]
|
||
exclude_qty = 0.0
|
||
if exclude_id and occupancy_keys:
|
||
occ_tuple = tuple(occupancy_keys)
|
||
by_occ = exclude_cache.setdefault(exclude_id, {})
|
||
if occ_tuple not in by_occ:
|
||
by_occ[occ_tuple] = _exclude_plan_qty_for_keys(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
contract_id=cid,
|
||
exclude_plan_id=exclude_id,
|
||
occupancy_keys=list(occupancy_keys),
|
||
)
|
||
exclude_qty = by_occ[occ_tuple]
|
||
|
||
arranged_qty = round(max(0.0, arranged_including - exclude_qty), 3)
|
||
available_qty = None
|
||
if has_fixed and contract_qty is not None:
|
||
available_qty = round(contract_qty - arranged_qty, 3)
|
||
|
||
out[idx] = {
|
||
"key": client_key,
|
||
"contractId": cid,
|
||
"contractNo": contract.contract_no or "",
|
||
"customerName": (contract.customer_name or "").strip() or None,
|
||
"contractProductId": str(getattr(cp, "id", None) or "").strip()
|
||
or cpid
|
||
or None,
|
||
"productId": pid,
|
||
"packageSpec": pkg or None,
|
||
"packageSpecs": package_specs or ([pkg] if pkg else []),
|
||
"packageBreakdown": [],
|
||
"productName": (cp.product_name if cp else None) or None,
|
||
"hasFixedQty": has_fixed,
|
||
"contractQty": contract_qty,
|
||
"arrangedQty": arranged_qty,
|
||
"availableQty": available_qty,
|
||
"plans": [],
|
||
}
|
||
|
||
return [row if row is not None else {"key": "", "plans": [], "hasFixedQty": False, "arrangedQty": 0.0, "productId": "", "contractId": ""} for row in out]
|
||
|
||
|
||
def collect_archive_line_overages(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan_id: str,
|
||
load_block: Any,
|
||
delivery_blocks: Optional[Iterable[Any]] = None,
|
||
) -> list[Dict[str, Any]]:
|
||
"""正式归档:实际数量超过合同行剩余时返回超交明细(不含确认标志)。"""
|
||
from app.models.crm_models import CrmContract, CrmContractProduct
|
||
|
||
pid = (plan_id or "").strip()
|
||
if not pid:
|
||
return []
|
||
|
||
load_ids, delivery_ids, load_products_by_load, delivery_products_by_delivery = (
|
||
_load_plan_stop_product_maps(db, tenant_no=tenant_no, plan_id=pid)
|
||
)
|
||
actual_by_id: Dict[str, float] = {}
|
||
if load_block is not None:
|
||
for item in getattr(load_block, "products", None) or []:
|
||
row_id = str(getattr(item, "id", None) or "").strip()
|
||
if not row_id:
|
||
continue
|
||
qty = getattr(item, "inbound_qty", None)
|
||
if qty is not None:
|
||
actual_by_id[row_id] = _to_float(qty)
|
||
for block in delivery_blocks or []:
|
||
for item in getattr(block, "products", None) or []:
|
||
row_id = str(getattr(item, "id", None) or "").strip()
|
||
if not row_id:
|
||
continue
|
||
qty = getattr(item, "outbound_qty", None)
|
||
if qty is not None:
|
||
actual_by_id[row_id] = _to_float(qty)
|
||
|
||
rows: list[Any] = []
|
||
for load_id in load_ids:
|
||
rows.extend(load_products_by_load.get(load_id, []))
|
||
for did in delivery_ids:
|
||
rows.extend(delivery_products_by_delivery.get(did, []))
|
||
|
||
qty_map_by_contract: Dict[str, Dict[ProductPackageKey, Dict[str, float]]] = {}
|
||
lines_by_contract: Dict[str, list] = {}
|
||
overages: list[Dict[str, Any]] = []
|
||
|
||
for row in rows:
|
||
cpid = str(getattr(row, "contract_product_id", None) or "").strip()
|
||
if not cpid:
|
||
continue
|
||
row_id = str(getattr(row, "id", None) or "").strip()
|
||
if row_id not in actual_by_id:
|
||
continue
|
||
actual = actual_by_id[row_id]
|
||
contract_id = str(getattr(row, "contract_id", None) or "").strip()
|
||
cp = (
|
||
db.query(CrmContractProduct)
|
||
.filter(
|
||
CrmContractProduct.id == cpid,
|
||
CrmContractProduct.tenant_no == tenant_no,
|
||
CrmContractProduct.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not cp:
|
||
continue
|
||
if not contract_id:
|
||
contract_id = str(cp.contract_id or "").strip()
|
||
contract = (
|
||
db.query(CrmContract)
|
||
.filter(
|
||
CrmContract.id == contract_id,
|
||
CrmContract.tenant_no == tenant_no,
|
||
CrmContract.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not contract:
|
||
continue
|
||
contract_type = (contract.contract_type or "").strip()
|
||
has_fixed = contract_product_has_fixed_qty(
|
||
contract_type=contract_type,
|
||
quantity=cp.quantity,
|
||
quantity_mode=cp.quantity_mode,
|
||
)
|
||
if not has_fixed:
|
||
continue
|
||
if contract_id not in qty_map_by_contract:
|
||
qty_map_by_contract[contract_id] = aggregate_contract_product_plan_qtys(
|
||
db, tenant_no=tenant_no, contract_id=contract_id
|
||
)
|
||
lines_by_contract[contract_id] = (
|
||
db.query(CrmContractProduct)
|
||
.filter(
|
||
CrmContractProduct.contract_id == contract_id,
|
||
CrmContractProduct.tenant_no == tenant_no,
|
||
CrmContractProduct.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
occ_keys = occupancy_keys_for_contract_line(
|
||
cp, lines_by_contract.get(contract_id) or [cp]
|
||
)
|
||
arranged_total = sum_qty_stats_for_keys(
|
||
qty_map_by_contract[contract_id], occ_keys
|
||
)["arrangedTotalQty"]
|
||
this_occupancy = _to_float(getattr(row, "plan_qty", None))
|
||
inbound = getattr(row, "inbound_qty", None)
|
||
outbound = getattr(row, "outbound_qty", None)
|
||
if inbound is not None:
|
||
this_occupancy = _to_float(inbound)
|
||
elif outbound is not None:
|
||
this_occupancy = _to_float(outbound)
|
||
evaluated = evaluate_product_qty_for_arrange(
|
||
has_fixed_qty=True,
|
||
contract_qty=_to_float(cp.quantity),
|
||
arranged_total=arranged_total,
|
||
old_plan_qty=this_occupancy,
|
||
new_plan_qty=actual,
|
||
)
|
||
if not evaluated or evaluated.get("kind") != "violation":
|
||
continue
|
||
specs = contract_line_package_specs(cp)
|
||
overages.append(
|
||
{
|
||
"stopProductId": row_id,
|
||
"contractId": contract_id,
|
||
"contractNo": contract.contract_no or "",
|
||
"contractProductId": cpid,
|
||
"productId": str(cp.product_id or ""),
|
||
"productName": cp.product_name or "",
|
||
"packageSpec": "、".join(specs) if specs else getattr(row, "package_spec", None),
|
||
"unitPrice": round(_to_float(cp.price), 2) if cp.price is not None else None,
|
||
"contractQty": evaluated["contractQty"],
|
||
"remainingQty": round(
|
||
evaluated["contractQty"] - (arranged_total - this_occupancy), 3
|
||
),
|
||
"actualQty": round(actual, 3),
|
||
"overflowQty": evaluated["overflowQty"],
|
||
}
|
||
)
|
||
return overages
|