"""合同产品在运输计划上的安排数量汇总。""" 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