| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115 |
- """合同产品在运输计划上的安排数量汇总。"""
- from __future__ import annotations
- from collections import defaultdict
- from typing import Any, DefaultDict, Dict, Iterable, Optional, Set
- from sqlalchemy.orm import Session
- from app.models.pl_models import (
- PlPlan,
- PlPlanContract,
- PlPlanDelivery,
- PlPlanDeliveryProduct,
- PlPlanLoad,
- PlPlanLoadProduct,
- )
- from app.services.pl_plan_service import (
- PLAN_CONTRACT_SIDE_DEMANDER,
- PLAN_CONTRACT_SIDE_SUPPLIER,
- )
- from app.services.pl_plan_stop_service import (
- PLAN_CONTRACT_SIDE_DELIVERY,
- PLAN_CONTRACT_SIDE_LOAD,
- )
- # 待安排 / 等通知 / 作废:不计入「已安排」(见 _bucket_for_plan)
- def _to_float(value: Any) -> float:
- if value is None:
- return 0.0
- try:
- return float(value)
- except (TypeError, ValueError):
- return 0.0
- def _bucket_for_plan(*, archive_status: Optional[str], follow_status: Optional[str], transport_status: Optional[str]) -> Optional[str]:
- """返回 'archived' | 'arranged_unarchived' | None(不计入)。"""
- follow = (follow_status or "").strip()
- if follow == "cancelled" or follow in ("wait_notice", "pending_arrange"):
- return None
- archive = (archive_status or "").strip() or "unarchived"
- if archive == "archived":
- return "archived"
- # 已安排未归档(含 archive draft 暂存)
- transport = (transport_status or "").strip()
- if transport == "arranged" or follow in ("arranged", "completed"):
- return "arranged_unarchived"
- return None
- def merge_product_qty_maps(
- *,
- archived: Dict[str, float],
- arranged_unarchived: Dict[str, float],
- ) -> Dict[str, Dict[str, float]]:
- """product_id -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}"""
- product_ids = set(archived) | set(arranged_unarchived)
- out: Dict[str, Dict[str, float]] = {}
- for pid in product_ids:
- a = round(float(archived.get(pid, 0.0)), 3)
- u = round(float(arranged_unarchived.get(pid, 0.0)), 3)
- out[pid] = {
- "archivedQty": a,
- "arrangedUnarchivedQty": u,
- "arrangedTotalQty": round(a + u, 3),
- }
- return out
- # 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[str, float],
- products: Iterable[Any],
- *,
- stop_kind: str = "load",
- qty_mode: str = QTY_MODE_PLAN,
- ) -> None:
- for row in products or []:
- pid = str(getattr(row, "product_id", None) or "").strip()
- if not pid:
- continue
- qty = _resolve_stop_product_qty(row, stop_kind=stop_kind, qty_mode=qty_mode)
- if qty:
- target[pid] += qty
- def resolve_linked_plan_scopes(
- link_rows: Iterable[PlPlanContract],
- legacy_plan_ids: Iterable[str],
- ) -> Dict[str, Dict[str, Any]]:
- """
- plan_id -> {
- load_ids: set[str] | None, # None = 该侧全部装货段
- delivery_ids: set[str] | None,
- use_all_stops: bool, # 遗留主表合同:优先送货段,否则装货段,各产品只计一次
- }
- """
- scopes: Dict[str, Dict[str, Any]] = {}
- def _ensure(plan_id: str) -> Dict[str, Any]:
- if plan_id not in scopes:
- scopes[plan_id] = {
- "load_ids": set(),
- "delivery_ids": set(),
- "use_all_stops": False,
- "has_explicit": False,
- }
- return scopes[plan_id]
- for row in link_rows or []:
- plan_id = str(getattr(row, "plan_id", None) or "").strip()
- if not plan_id:
- continue
- scope = _ensure(plan_id)
- side = (getattr(row, "side", None) or "").strip()
- stop_id = str(getattr(row, "stop_id", None) or "").strip()
- scope["has_explicit"] = True
- if side in (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER):
- if stop_id:
- scope["load_ids"].add(stop_id)
- else:
- scope["load_ids"] = None # type: ignore[assignment]
- elif side in (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER):
- if stop_id:
- scope["delivery_ids"].add(stop_id)
- else:
- scope["delivery_ids"] = None # type: ignore[assignment]
- elif stop_id:
- # 未知 side 但有 stop:两边都尝试匹配时由调用方按 id 过滤
- scope["load_ids"].add(stop_id)
- scope["delivery_ids"].add(stop_id)
- for plan_id in legacy_plan_ids or []:
- pid = str(plan_id or "").strip()
- if not pid:
- continue
- scope = _ensure(pid)
- if not scope["has_explicit"]:
- scope["use_all_stops"] = True
- return scopes
- def sum_qtys_for_scope(
- *,
- load_products_by_load: Dict[str, list],
- delivery_products_by_delivery: Dict[str, list],
- all_load_ids: Set[str],
- all_delivery_ids: Set[str],
- scope: Dict[str, Any],
- qty_mode: str = QTY_MODE_PLAN,
- ) -> Dict[str, float]:
- acc: DefaultDict[str, float] = defaultdict(float)
- if scope.get("use_all_stops"):
- # 遗留:优先送货段数量;无送货则用装货,避免装+送双计
- if all_delivery_ids:
- for did in all_delivery_ids:
- accumulate_stop_products(
- acc,
- delivery_products_by_delivery.get(did, []),
- 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[str, Dict[str, float]]:
- """返回 productId -> 三数量字段。"""
- cid = (contract_id or "").strip()
- if not cid:
- return {}
- link_rows = (
- db.query(PlPlanContract)
- .filter(
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- PlPlanContract.contract_id == cid,
- )
- .all()
- )
- link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
- legacy_plans = (
- db.query(PlPlan)
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.contract_id == cid,
- )
- .all()
- )
- legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
- scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
- plan_ids = list(scopes.keys())
- if not plan_ids:
- return {}
- plans = (
- db.query(PlPlan)
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.id.in_(plan_ids),
- PlPlan.follow_status != "cancelled",
- )
- .all()
- )
- plan_by_id = {p.id: p for p in plans}
- loads = (
- db.query(PlPlanLoad)
- .filter(
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- PlPlanLoad.plan_id.in_(plan_ids),
- )
- .all()
- )
- deliveries = (
- db.query(PlPlanDelivery)
- .filter(
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- PlPlanDelivery.plan_id.in_(plan_ids),
- )
- .all()
- )
- load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
- delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
- for row in loads:
- load_ids_by_plan[row.plan_id].add(row.id)
- for row in deliveries:
- delivery_ids_by_plan[row.plan_id].add(row.id)
- all_load_ids = {row.id for row in loads}
- all_delivery_ids = {row.id for row in deliveries}
- load_products_by_load: DefaultDict[str, list] = defaultdict(list)
- if all_load_ids:
- for prod in (
- db.query(PlPlanLoadProduct)
- .filter(
- PlPlanLoadProduct.tenant_no == tenant_no,
- PlPlanLoadProduct.del_flag == 0,
- PlPlanLoadProduct.load_id.in_(all_load_ids),
- )
- .all()
- ):
- load_products_by_load[prod.load_id].append(prod)
- delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
- if all_delivery_ids:
- for prod in (
- db.query(PlPlanDeliveryProduct)
- .filter(
- PlPlanDeliveryProduct.tenant_no == tenant_no,
- PlPlanDeliveryProduct.del_flag == 0,
- PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
- )
- .all()
- ):
- delivery_products_by_delivery[prod.delivery_id].append(prod)
- archived: DefaultDict[str, float] = defaultdict(float)
- arranged_unarchived: DefaultDict[str, float] = defaultdict(float)
- for plan_id, scope in scopes.items():
- plan = plan_by_id.get(plan_id)
- if not plan:
- continue
- bucket = _bucket_for_plan(
- archive_status=plan.archive_status,
- follow_status=plan.follow_status,
- transport_status=plan.transport_status,
- )
- if not bucket:
- continue
- 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[str, Dict[str, float]],
- ) -> None:
- for item in products or []:
- if not isinstance(item, dict):
- continue
- pid = str(item.get("productId") or "").strip()
- stats = qty_by_product_id.get(pid) or {
- "archivedQty": 0.0,
- "arrangedUnarchivedQty": 0.0,
- "arrangedTotalQty": 0.0,
- }
- item["archivedQty"] = stats["archivedQty"]
- item["arrangedUnarchivedQty"] = stats["arrangedUnarchivedQty"]
- item["arrangedTotalQty"] = stats["arrangedTotalQty"]
- def plan_ids_linked_to_contract(
- db: Session,
- *,
- tenant_no: str,
- contract_id: str,
- ) -> Set[str]:
- cid = (contract_id or "").strip()
- if not cid:
- return set()
- ids: Set[str] = set()
- for (plan_id,) in (
- db.query(PlPlanContract.plan_id)
- .filter(
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- PlPlanContract.contract_id == cid,
- )
- .distinct()
- .all()
- ):
- if plan_id:
- ids.add(str(plan_id))
- for (plan_id,) in (
- db.query(PlPlan.id)
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.contract_id == cid,
- )
- .all()
- ):
- if plan_id:
- ids.add(str(plan_id))
- return ids
- def contract_ids_linked_to_plan(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- ) -> Set[str]:
- ids: Set[str] = set()
- if plan.contract_id:
- ids.add(str(plan.contract_id).strip())
- for (cid,) in (
- db.query(PlPlanContract.contract_id)
- .filter(
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- PlPlanContract.plan_id == plan.id,
- )
- .distinct()
- .all()
- ):
- if cid:
- ids.add(str(cid).strip())
- return {x for x in ids if x}
- def auto_start_execute_approved_contracts(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- user_id: Optional[str] = None,
- ) -> list[str]:
- """将关联且状态为 approved 的合同置为 executing(不 commit)。返回已推进的合同 id。"""
- from datetime import datetime
- from app.models.crm_models import CrmContract
- started: list[str] = []
- for cid in contract_ids_linked_to_plan(db, tenant_no=tenant_no, plan=plan):
- row = (
- db.query(CrmContract)
- .filter(
- CrmContract.id == cid,
- CrmContract.tenant_no == tenant_no,
- CrmContract.del_flag == 0,
- )
- .first()
- )
- if not row or row.status != "approved":
- continue
- row.status = "executing"
- row.update_by = user_id
- row.update_time = datetime.now()
- started.append(cid)
- return started
- # =============================================================================
- # 安排运输:合同数量硬拦 / 成功后提醒
- # =============================================================================
- FRAMEWORK_CONTRACT_TYPES = frozenset({"framework", "framework_sale", "framework_purchase"})
- def effective_arranged_total(
- *,
- arranged_total: float,
- old_plan_qty: float,
- new_plan_qty: float,
- ) -> float:
- """已安排合计 − 本计划旧量 + 本计划新量。"""
- return round(
- _to_float(arranged_total) - _to_float(old_plan_qty) + _to_float(new_plan_qty),
- 3,
- )
- def contract_product_has_fixed_qty(
- *,
- contract_type: Optional[str],
- quantity: Any,
- quantity_mode: Optional[str],
- ) -> bool:
- """合同产品行是否按固定数量做超量硬拦。"""
- ctype = (contract_type or "").strip()
- mode = (quantity_mode or "").strip() or "fixed"
- if ctype in FRAMEWORK_CONTRACT_TYPES and mode == "customer_demand":
- return False
- qty = _to_float(quantity)
- return qty > 0
- def evaluate_product_qty_for_arrange(
- *,
- has_fixed_qty: bool,
- contract_qty: Optional[float],
- arranged_total: float,
- old_plan_qty: float,
- new_plan_qty: float,
- ) -> Optional[Dict[str, Any]]:
- """
- 返回:
- - None: 跳过(本次无量)
- - kind=violation | remaining | framework_cumulative
- """
- new_qty = _to_float(new_plan_qty)
- if new_qty <= 0:
- return None
- old_qty = _to_float(old_plan_qty)
- arranged = _to_float(arranged_total)
- effective = effective_arranged_total(
- arranged_total=arranged,
- old_plan_qty=old_qty,
- new_plan_qty=new_qty,
- )
- if not has_fixed_qty:
- return {
- "kind": "framework_cumulative",
- "arrangedTotalQty": effective,
- "thisPlanQty": round(new_qty, 3),
- "effectiveTotal": effective,
- }
- cq = _to_float(contract_qty)
- overflow = round(effective - cq, 3)
- base = {
- "contractQty": round(cq, 3),
- "arrangedTotalQty": effective,
- "thisPlanQty": round(new_qty, 3),
- "oldPlanQty": round(old_qty, 3),
- "newPlanQty": round(new_qty, 3),
- "effectiveTotal": effective,
- }
- if overflow > 0:
- return {**base, "kind": "violation", "overflowQty": overflow}
- return {
- **base,
- "kind": "remaining",
- "remainingQty": round(cq - effective, 3),
- }
- CONTRACT_QTY_VIOLATION_CODE = "CONTRACT_QTY_OVERFLOW"
- def _stop_kind_from_scope(scope: Dict[str, Any]) -> str:
- load_ids = scope.get("load_ids")
- delivery_ids = scope.get("delivery_ids")
- has_load = load_ids is None or bool(load_ids)
- has_delivery = delivery_ids is None or bool(delivery_ids)
- if scope.get("use_all_stops"):
- return "delivery"
- if has_load and not has_delivery:
- return "load"
- if has_delivery and not has_load:
- return "delivery"
- if has_load:
- return "load"
- return "delivery"
- def _plan_scope_for_contract(
- *,
- plan_id: str,
- contract_id: str,
- link_rows: list[PlPlanContract],
- legacy_contract_id: Optional[str],
- ) -> Dict[str, Any]:
- cid = (contract_id or "").strip()
- links = [r for r in link_rows if str(r.contract_id or "").strip() == cid]
- legacy_ids: list[str] = []
- if not links and legacy_contract_id and str(legacy_contract_id).strip() == cid:
- legacy_ids = [plan_id]
- scopes = resolve_linked_plan_scopes(links, legacy_ids)
- return scopes.get(plan_id) or {
- "load_ids": set(),
- "delivery_ids": set(),
- "use_all_stops": False,
- "has_explicit": False,
- }
- def _load_plan_stop_product_maps(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- ) -> tuple[Set[str], Set[str], Dict[str, list], Dict[str, list]]:
- loads = (
- db.query(PlPlanLoad)
- .filter(
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- PlPlanLoad.plan_id == plan_id,
- )
- .all()
- )
- deliveries = (
- db.query(PlPlanDelivery)
- .filter(
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- PlPlanDelivery.plan_id == plan_id,
- )
- .all()
- )
- load_ids = {row.id for row in loads}
- delivery_ids = {row.id for row in deliveries}
- load_products_by_load: DefaultDict[str, list] = defaultdict(list)
- if load_ids:
- for prod in (
- db.query(PlPlanLoadProduct)
- .filter(
- PlPlanLoadProduct.tenant_no == tenant_no,
- PlPlanLoadProduct.del_flag == 0,
- PlPlanLoadProduct.load_id.in_(load_ids),
- )
- .all()
- ):
- load_products_by_load[prod.load_id].append(prod)
- delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
- if delivery_ids:
- for prod in (
- db.query(PlPlanDeliveryProduct)
- .filter(
- PlPlanDeliveryProduct.tenant_no == tenant_no,
- PlPlanDeliveryProduct.del_flag == 0,
- PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
- )
- .all()
- ):
- delivery_products_by_delivery[prod.delivery_id].append(prod)
- return load_ids, delivery_ids, dict(load_products_by_load), dict(delivery_products_by_delivery)
- def collect_plan_contract_qty_checks(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- ) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
- """
- 返回 (violations, reminders)。
- 在写运输前调用;reminders 可缓存到成功响应,勿在状态变更后再算。
- """
- from app.models.crm_models import CrmContract, CrmContractProduct
- plan = (
- db.query(PlPlan)
- .filter(
- PlPlan.id == plan_id,
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- )
- .first()
- )
- if not plan:
- return [], []
- link_rows = (
- db.query(PlPlanContract)
- .filter(
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- PlPlanContract.plan_id == plan_id,
- )
- .all()
- )
- contract_ids: Set[str] = set()
- for row in link_rows:
- cid = str(row.contract_id or "").strip()
- if cid:
- contract_ids.add(cid)
- legacy_cid = str(plan.contract_id or "").strip()
- if legacy_cid:
- contract_ids.add(legacy_cid)
- if not contract_ids:
- return [], []
- load_ids, delivery_ids, load_products_by_load, delivery_products_by_delivery = (
- _load_plan_stop_product_maps(db, tenant_no=tenant_no, plan_id=plan_id)
- )
- in_bucket = (
- _bucket_for_plan(
- archive_status=plan.archive_status,
- follow_status=plan.follow_status,
- transport_status=plan.transport_status,
- )
- is not None
- )
- violations: list[Dict[str, Any]] = []
- reminders: list[Dict[str, Any]] = []
- for cid in sorted(contract_ids):
- contract = (
- db.query(CrmContract)
- .filter(
- CrmContract.id == cid,
- CrmContract.tenant_no == tenant_no,
- CrmContract.del_flag == 0,
- )
- .first()
- )
- if not contract:
- continue
- scope = _plan_scope_for_contract(
- plan_id=plan_id,
- contract_id=cid,
- link_rows=link_rows,
- legacy_contract_id=legacy_cid,
- )
- plan_qtys = sum_qtys_for_scope(
- load_products_by_load=load_products_by_load,
- delivery_products_by_delivery=delivery_products_by_delivery,
- all_load_ids=load_ids,
- all_delivery_ids=delivery_ids,
- scope=scope,
- )
- if not plan_qtys:
- continue
- qty_map = aggregate_contract_product_plan_qtys(
- db, tenant_no=tenant_no, contract_id=cid
- )
- products = (
- db.query(CrmContractProduct)
- .filter(
- CrmContractProduct.contract_id == cid,
- CrmContractProduct.tenant_no == tenant_no,
- CrmContractProduct.del_flag == 0,
- )
- .all()
- )
- products_by_pid = {
- str(p.product_id or "").strip(): p for p in products if p.product_id
- }
- stop_kind = _stop_kind_from_scope(scope)
- contract_type = (contract.contract_type or "").strip()
- contract_no = contract.contract_no or ""
- customer_name = (contract.customer_name or "").strip()
- for pid, new_qty_raw in plan_qtys.items():
- new_qty = _to_float(new_qty_raw)
- if new_qty <= 0:
- continue
- cp = products_by_pid.get(pid)
- # 计划产品不在合同明细:不校验数量(归属另有规则)
- if not cp:
- continue
- arranged_total = _to_float((qty_map.get(pid) or {}).get("arrangedTotalQty"))
- if in_bucket:
- old_qty = new_qty
- # arranged 已含当前库内量;effective = arranged - old + new = arranged
- else:
- old_qty = 0.0
- has_fixed = contract_product_has_fixed_qty(
- contract_type=contract_type,
- quantity=cp.quantity,
- quantity_mode=cp.quantity_mode,
- )
- evaluated = evaluate_product_qty_for_arrange(
- has_fixed_qty=has_fixed,
- contract_qty=_to_float(cp.quantity) if has_fixed else None,
- arranged_total=arranged_total,
- old_plan_qty=old_qty,
- new_plan_qty=new_qty,
- )
- if not evaluated:
- continue
- item = {
- "contractId": cid,
- "contractNo": contract_no,
- "contractType": contract_type,
- "customerName": customer_name,
- "stopKind": stop_kind,
- "productId": pid,
- "productName": cp.product_name or "",
- "model": (cp.model or "").strip() or None,
- "unit": (cp.unit or "").strip() or 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 get_contract_product_arrange_qty_detail(
- db: Session,
- *,
- tenant_no: str,
- contract_id: str,
- product_id: str,
- exclude_plan_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()
- pid = (product_id or "").strip()
- exclude_id = (exclude_plan_id or "").strip() or None
- empty = {
- "contractId": cid,
- "contractNo": None,
- "customerName": None,
- "productId": pid,
- "productName": None,
- "hasFixedQty": False,
- "contractQty": None,
- "arrangedQty": 0.0,
- "availableQty": None,
- "plans": [],
- }
- if not cid or not pid:
- 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
- cp = (
- db.query(CrmContractProduct)
- .filter(
- CrmContractProduct.contract_id == cid,
- CrmContractProduct.product_id == pid,
- CrmContractProduct.tenant_no == tenant_no,
- CrmContractProduct.del_flag == 0,
- )
- .first()
- )
- contract_type = (contract.contract_type or "").strip()
- has_fixed = False
- contract_qty: Optional[float] = None
- if cp:
- 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 = _to_float((qty_map.get(pid) or {}).get("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 = _to_float(qtys.get(pid))
- 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,
- "productId": pid,
- "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,
- }
|