|
|
@@ -0,0 +1,814 @@
|
|
|
+"""合同产品在运输计划上的安排数量汇总。"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from collections import defaultdict
|
|
|
+from typing import Any, DefaultDict, Dict, Iterable, Optional, Set
|
|
|
+
|
|
|
+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,
|
|
|
+)
|
|
|
+
|
|
|
+# 待安排 / 等通知 / 作废:不计入「已安排」(见 _bucket_for_plan)
|
|
|
+
|
|
|
+
|
|
|
+def _to_float(value: Any) -> float:
|
|
|
+ if value is None:
|
|
|
+ return 0.0
|
|
|
+ try:
|
|
|
+ return float(value)
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+
|
|
|
+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[str, float],
|
|
|
+ arranged_unarchived: Dict[str, float],
|
|
|
+) -> Dict[str, Dict[str, float]]:
|
|
|
+ """product_id -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}"""
|
|
|
+ product_ids = set(archived) | set(arranged_unarchived)
|
|
|
+ out: Dict[str, Dict[str, float]] = {}
|
|
|
+ for pid in product_ids:
|
|
|
+ a = round(float(archived.get(pid, 0.0)), 3)
|
|
|
+ u = round(float(arranged_unarchived.get(pid, 0.0)), 3)
|
|
|
+ out[pid] = {
|
|
|
+ "archivedQty": a,
|
|
|
+ "arrangedUnarchivedQty": u,
|
|
|
+ "arrangedTotalQty": round(a + u, 3),
|
|
|
+ }
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def accumulate_stop_products(
|
|
|
+ target: DefaultDict[str, float],
|
|
|
+ products: Iterable[Any],
|
|
|
+) -> None:
|
|
|
+ for row in products or []:
|
|
|
+ pid = str(getattr(row, "product_id", None) or "").strip()
|
|
|
+ if not pid:
|
|
|
+ continue
|
|
|
+ qty = _to_float(getattr(row, "plan_qty", None))
|
|
|
+ if qty:
|
|
|
+ target[pid] += 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],
|
|
|
+) -> Dict[str, float]:
|
|
|
+ acc: DefaultDict[str, 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, []))
|
|
|
+ else:
|
|
|
+ for lid in all_load_ids:
|
|
|
+ accumulate_stop_products(acc, load_products_by_load.get(lid, []))
|
|
|
+ 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, []))
|
|
|
+ elif load_ids:
|
|
|
+ for lid in load_ids:
|
|
|
+ accumulate_stop_products(acc, load_products_by_load.get(lid, []))
|
|
|
+
|
|
|
+ if delivery_ids is None:
|
|
|
+ for did in all_delivery_ids:
|
|
|
+ accumulate_stop_products(acc, delivery_products_by_delivery.get(did, []))
|
|
|
+ elif delivery_ids:
|
|
|
+ for did in delivery_ids:
|
|
|
+ accumulate_stop_products(acc, delivery_products_by_delivery.get(did, []))
|
|
|
+
|
|
|
+ return dict(acc)
|
|
|
+
|
|
|
+
|
|
|
+def aggregate_contract_product_plan_qtys(
|
|
|
+ db: Session,
|
|
|
+ *,
|
|
|
+ tenant_no: str,
|
|
|
+ contract_id: str,
|
|
|
+) -> Dict[str, Dict[str, float]]:
|
|
|
+ """返回 productId -> 三数量字段。"""
|
|
|
+ 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[str, float] = defaultdict(float)
|
|
|
+ arranged_unarchived: DefaultDict[str, 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
|
|
|
+ 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,
|
|
|
+ )
|
|
|
+ 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[str, Dict[str, float]],
|
|
|
+) -> None:
|
|
|
+ for item in products or []:
|
|
|
+ if not isinstance(item, dict):
|
|
|
+ continue
|
|
|
+ pid = str(item.get("productId") or "").strip()
|
|
|
+ stats = qty_by_product_id.get(pid) or {
|
|
|
+ "archivedQty": 0.0,
|
|
|
+ "arrangedUnarchivedQty": 0.0,
|
|
|
+ "arrangedTotalQty": 0.0,
|
|
|
+ }
|
|
|
+ item["archivedQty"] = stats["archivedQty"]
|
|
|
+ item["arrangedUnarchivedQty"] = stats["arrangedUnarchivedQty"]
|
|
|
+ item["arrangedTotalQty"] = stats["arrangedTotalQty"]
|
|
|
+
|
|
|
+
|
|
|
+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"
|
|
|
+
|
|
|
+
|
|
|
+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()
|
|
|
+ )
|
|
|
+ products_by_pid = {
|
|
|
+ str(p.product_id or "").strip(): p for p in products if p.product_id
|
|
|
+ }
|
|
|
+ stop_kind = _stop_kind_from_scope(scope)
|
|
|
+ contract_type = (contract.contract_type or "").strip()
|
|
|
+ contract_no = contract.contract_no or ""
|
|
|
+
|
|
|
+ for pid, new_qty_raw in plan_qtys.items():
|
|
|
+ new_qty = _to_float(new_qty_raw)
|
|
|
+ if new_qty <= 0:
|
|
|
+ continue
|
|
|
+ cp = products_by_pid.get(pid)
|
|
|
+ # 计划产品不在合同明细:不校验数量(归属另有规则)
|
|
|
+ if not cp:
|
|
|
+ continue
|
|
|
+
|
|
|
+ arranged_total = _to_float((qty_map.get(pid) or {}).get("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
|
|
|
+
|
|
|
+ item = {
|
|
|
+ "contractId": cid,
|
|
|
+ "contractNo": contract_no,
|
|
|
+ "contractType": contract_type,
|
|
|
+ "stopKind": stop_kind,
|
|
|
+ "productId": pid,
|
|
|
+ "productName": cp.product_name or "",
|
|
|
+ "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"],
|
|
|
+ }
|
|
|
+ )
|
|
|
+ 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}
|