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