Files
shanxia2.0/backend/app/services/pl_contract_plan_qty.py
T
2026-10-06 17:55:20 +08:00

1970 lines
66 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 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