| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688 |
- from __future__ import annotations
- from datetime import date, datetime
- from typing import List, Optional, TYPE_CHECKING
- from fastapi import HTTPException
- from sqlalchemy import and_, exists, func, or_, select
- from sqlalchemy.orm import Session
- from app.core.utils import generate_uuid
- from app.models.pl_models import (
- PlPlan,
- PlPlanContract,
- PlPlanDelivery,
- PlPlanLoad,
- PlPlanProduct,
- )
- from app.schemas.pl.plan import (
- PlanContractGapRead,
- PlanContractLink,
- PlanDeliveryRead,
- PlanLoadRead,
- PlanProductRead,
- )
- from app.services.pl_data_scope import apply_pl_scope
- from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES, is_internal_customer_name
- from app.services.pl_plan_service import (
- PLAN_CONTRACT_SIDE_DEMANDER,
- PLAN_CONTRACT_SIDE_SUPPLIER,
- _normalize_contract_links,
- assert_contract_selectable_for_plan,
- load_plan_contracts_grouped,
- replace_plan_contracts,
- )
- from app.services.pl_plan_stop_service import (
- PLAN_CONTRACT_SIDE_DELIVERY,
- PLAN_CONTRACT_SIDE_LOAD,
- assert_at_most_one_contract,
- load_plan_stops_grouped,
- )
- from app.services.pl_product_display import load_crm_product_alias_maps, resolve_product_display_names
- if TYPE_CHECKING:
- from app.core.db_auth import DbUserContext
- def stop_contract_gap(customer_name: Optional[str], contract_count: int) -> bool:
- """段次客户非内置且未关联合同时视为缺口。"""
- return not is_internal_customer_name(customer_name) and contract_count <= 0
- def side_is_exempt(name: Optional[str]) -> bool:
- return is_internal_customer_name(name)
- def compute_contract_gap_sides(
- *,
- supplier_name: Optional[str],
- customer_name: Optional[str],
- supplier_contract_count: int,
- demander_contract_count: int,
- ) -> List[str]:
- gaps: List[str] = []
- if stop_contract_gap(supplier_name, supplier_contract_count):
- gaps.append("supplier")
- if stop_contract_gap(customer_name, demander_contract_count):
- gaps.append("demander")
- return gaps
- def compute_plan_gap_sides_from_stops(
- load_read: Optional[PlanLoadRead],
- delivery_reads: list[PlanDeliveryRead],
- *,
- supplier_name: Optional[str] = None,
- customer_name: Optional[str] = None,
- supplier_contract_count: int = 0,
- demander_contract_count: int = 0,
- ) -> list[str]:
- """按段次汇总计划是否仍有合同缺口(supplier=装货侧,demander=送货侧)。"""
- has_load_gap = False
- has_delivery_gap = False
- if load_read is not None:
- has_load_gap = stop_contract_gap(load_read.customer_name, len(load_read.contracts or []))
- elif stop_contract_gap(supplier_name, supplier_contract_count):
- has_load_gap = True
- if delivery_reads:
- has_delivery_gap = any(
- stop_contract_gap(item.customer_name, len(item.contracts or []))
- for item in delivery_reads
- )
- elif stop_contract_gap(customer_name, demander_contract_count):
- has_delivery_gap = True
- gaps: list[str] = []
- if has_load_gap:
- gaps.append("supplier")
- if has_delivery_gap:
- gaps.append("demander")
- return gaps
- def _stop_contract_exists(tenant_no: str, plan_id_expr, stop_id_expr, legacy_sides: tuple[str, ...]):
- return exists(
- select(PlPlanContract.id).where(
- PlPlanContract.plan_id == plan_id_expr,
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- or_(
- PlPlanContract.stop_id == stop_id_expr,
- and_(
- or_(PlPlanContract.stop_id.is_(None), PlPlanContract.stop_id == ""),
- PlPlanContract.side.in_(legacy_sides),
- ),
- ),
- )
- )
- def _load_stop_gap_expr(tenant_no: str):
- internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
- customer_not_internal = or_(
- PlPlanLoad.customer_name.is_(None),
- PlPlanLoad.customer_name == "",
- ~PlPlanLoad.customer_name.in_(internal_names),
- )
- return exists(
- select(PlPlanLoad.id).where(
- PlPlanLoad.plan_id == PlPlan.id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- customer_not_internal,
- ~_stop_contract_exists(
- tenant_no,
- PlPlan.id,
- PlPlanLoad.id,
- (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER),
- ),
- )
- )
- def _delivery_stop_gap_expr(tenant_no: str):
- internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
- customer_not_internal = or_(
- PlPlanDelivery.customer_name.is_(None),
- PlPlanDelivery.customer_name == "",
- ~PlPlanDelivery.customer_name.in_(internal_names),
- )
- return exists(
- select(PlPlanDelivery.id).where(
- PlPlanDelivery.plan_id == PlPlan.id,
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- customer_not_internal,
- ~_stop_contract_exists(
- tenant_no,
- PlPlan.id,
- PlPlanDelivery.id,
- (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER),
- ),
- )
- )
- def _legacy_supplier_gap_expr(tenant_no: str):
- internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
- has_load = exists(
- select(PlPlanLoad.id).where(
- PlPlanLoad.plan_id == PlPlan.id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- )
- )
- supplier_not_internal = or_(
- PlPlan.supplier_name.is_(None),
- PlPlan.supplier_name == "",
- ~PlPlan.supplier_name.in_(internal_names),
- )
- return and_(
- ~has_load,
- supplier_not_internal,
- ~_stop_contract_exists(
- tenant_no,
- PlPlan.id,
- PlPlan.id,
- (PLAN_CONTRACT_SIDE_SUPPLIER,),
- ),
- )
- def _legacy_demander_gap_expr(tenant_no: str):
- internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
- has_delivery = exists(
- select(PlPlanDelivery.id).where(
- PlPlanDelivery.plan_id == PlPlan.id,
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- )
- )
- customer_not_internal = or_(
- PlPlan.customer_name.is_(None),
- PlPlan.customer_name == "",
- ~PlPlan.customer_name.in_(internal_names),
- )
- return and_(
- ~has_delivery,
- customer_not_internal,
- ~_stop_contract_exists(
- tenant_no,
- PlPlan.id,
- PlPlan.id,
- (PLAN_CONTRACT_SIDE_DEMANDER,),
- ),
- )
- def plan_has_contract_gap_exists(tenant_no: str):
- """列表筛选:任一段次(或旧计划级)存在合同缺口。"""
- return or_(
- _load_stop_gap_expr(tenant_no),
- _delivery_stop_gap_expr(tenant_no),
- _legacy_supplier_gap_expr(tenant_no),
- _legacy_demander_gap_expr(tenant_no),
- )
- def _supplier_gap_expr(tenant_no: str):
- return or_(_load_stop_gap_expr(tenant_no), _legacy_supplier_gap_expr(tenant_no))
- def _demander_gap_expr(tenant_no: str):
- return or_(_delivery_stop_gap_expr(tenant_no), _legacy_demander_gap_expr(tenant_no))
- def build_plan_contract_gap_read(
- plan: PlPlan,
- *,
- supplier_contracts: list[PlanContractLink],
- demander_contracts: list[PlanContractLink],
- products: Optional[list[PlanProductRead]] = None,
- load_read: Optional[PlanLoadRead] = None,
- delivery_reads: Optional[list[PlanDeliveryRead]] = None,
- ) -> PlanContractGapRead:
- deliveries = list(delivery_reads or [])
- gap_sides = compute_plan_gap_sides_from_stops(
- load_read,
- deliveries,
- supplier_name=plan.supplier_name,
- customer_name=plan.customer_name,
- supplier_contract_count=len(supplier_contracts),
- demander_contract_count=len(demander_contracts),
- )
- return PlanContractGapRead(
- id=plan.id,
- planNo=plan.plan_no,
- planDate=plan.plan_date,
- planType=plan.plan_type,
- followStatus=plan.follow_status,
- supplierId=plan.supplier_id,
- supplierName=plan.supplier_name,
- customerId=plan.customer_id,
- customerName=plan.customer_name,
- gapSides=gap_sides,
- supplierContractExempt=side_is_exempt(plan.supplier_name),
- demanderContractExempt=side_is_exempt(plan.customer_name),
- supplierContracts=supplier_contracts,
- demanderContracts=demander_contracts,
- products=list(products or []),
- load=load_read,
- deliveries=deliveries,
- )
- def _load_products_by_plan_ids(
- db: Session,
- plan_ids: list[str],
- *,
- tenant_no: str,
- ) -> dict[str, list[PlanProductRead]]:
- if not plan_ids:
- return {}
- rows = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id.in_(plan_ids),
- PlPlanProduct.del_flag == 0,
- )
- .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
- .all()
- )
- product_ids = {(row.product_id or "").strip() for row in rows if (row.product_id or "").strip()}
- standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
- db, tenant_no=tenant_no, product_ids=product_ids
- )
- out: dict[str, list[PlanProductRead]] = {pid: [] for pid in plan_ids}
- for row in rows:
- item = PlanProductRead.model_validate(row)
- display_name, _alias = resolve_product_display_names(
- saved_product_name=row.product_name,
- product_id=row.product_id,
- standard_by_id=standard_by_id,
- alias_options_by_id=alias_options_by_id,
- )
- item.product_name = display_name or (row.product_name or "")
- out.setdefault(row.plan_id, []).append(item)
- return out
- def list_plan_contract_gaps(
- db: Session,
- *,
- tenant_no: str,
- user: "DbUserContext",
- page: int = 1,
- page_size: int = 20,
- plan_no: Optional[str] = None,
- gap_type: Optional[str] = None,
- customer_keyword: Optional[str] = None,
- plan_date_from: Optional[date] = None,
- plan_date_to: Optional[date] = None,
- q: Optional[str] = None,
- ids: Optional[list] = None,
- max_page_size: Optional[int] = None,
- ) -> tuple[int, list[PlanContractGapRead]]:
- query = db.query(PlPlan).filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.follow_status != "cancelled",
- plan_has_contract_gap_exists(tenant_no),
- )
- query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
- if plan_no:
- query = query.filter(PlPlan.plan_no.contains(plan_no))
- gap_type_norm = (gap_type or "").strip().lower()
- if gap_type_norm == "supplier":
- query = query.filter(_supplier_gap_expr(tenant_no))
- elif gap_type_norm == "demander":
- query = query.filter(_demander_gap_expr(tenant_no))
- elif gap_type_norm == "both":
- query = query.filter(
- and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
- )
- if customer_keyword:
- kw = customer_keyword.strip()
- if kw:
- kw_lower = kw.lower()
- load_match = exists(
- select(PlPlanLoad.id).where(
- PlPlanLoad.plan_id == PlPlan.id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- func.lower(PlPlanLoad.customer_name).contains(kw_lower),
- )
- )
- delivery_match = exists(
- select(PlPlanDelivery.id).where(
- PlPlanDelivery.plan_id == PlPlan.id,
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- func.lower(PlPlanDelivery.customer_name).contains(kw_lower),
- )
- )
- query = query.filter(
- or_(
- func.lower(PlPlan.supplier_name).contains(kw_lower),
- func.lower(PlPlan.customer_name).contains(kw_lower),
- load_match,
- delivery_match,
- )
- )
- if plan_date_from:
- query = query.filter(PlPlan.plan_date >= plan_date_from)
- if plan_date_to:
- query = query.filter(PlPlan.plan_date <= plan_date_to)
- if q:
- q_kw = q.strip()
- if q_kw:
- q_lower = q_kw.lower()
- load_match = exists(
- select(PlPlanLoad.id).where(
- PlPlanLoad.plan_id == PlPlan.id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- func.lower(PlPlanLoad.customer_name).contains(q_lower),
- )
- )
- delivery_match = exists(
- select(PlPlanDelivery.id).where(
- PlPlanDelivery.plan_id == PlPlan.id,
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- func.lower(PlPlanDelivery.customer_name).contains(q_lower),
- )
- )
- query = query.filter(
- or_(
- PlPlan.plan_no.contains(q_kw),
- func.lower(PlPlan.supplier_name).contains(q_lower),
- func.lower(PlPlan.customer_name).contains(q_lower),
- load_match,
- delivery_match,
- )
- )
- if ids:
- query = query.filter(PlPlan.id.in_(ids))
- query = query.order_by(PlPlan.plan_date.desc(), PlPlan.create_time.desc())
- total = query.count()
- page = max(1, page)
- cap = int(max_page_size) if max_page_size else 500
- page_size = min(max(1, page_size), cap)
- offset = (page - 1) * page_size
- plans = query.offset(offset).limit(page_size).all()
- plan_ids = [plan.id for plan in plans]
- contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
- products_by_plan = _load_products_by_plan_ids(db, plan_ids, tenant_no=tenant_no)
- stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
- items: list[PlanContractGapRead] = []
- for plan in plans:
- supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
- load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
- items.append(
- build_plan_contract_gap_read(
- plan,
- supplier_contracts=supplier_contracts,
- demander_contracts=demander_contracts,
- products=products_by_plan.get(plan.id, []),
- load_read=load_read,
- delivery_reads=delivery_reads,
- )
- )
- return total, items
- def _write_stop_contract_rows(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- stop_id: str,
- side: str,
- contracts: list[PlanContractLink],
- user_id: Optional[str],
- dept_id: Optional[str],
- ) -> None:
- now = datetime.now()
- db.query(PlPlanContract).filter(
- PlPlanContract.plan_id == plan_id,
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- PlPlanContract.stop_id == stop_id,
- ).update(
- {"del_flag": 1, "update_by": user_id, "update_time": now},
- synchronize_session=False,
- )
- order_num = 0
- for link in contracts:
- order_num += 1
- db.add(
- PlPlanContract(
- id=generate_uuid(),
- plan_id=plan_id,
- side=side,
- stop_id=stop_id,
- contract_id=link.contract_id,
- contract_no=link.contract_no,
- tenant_no=tenant_no,
- order_num=order_num,
- create_by=user_id,
- create_dept_id=dept_id or "",
- create_time=now,
- update_by=user_id,
- update_time=now,
- del_flag=0,
- )
- )
- def _clear_legacy_side_contracts(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- legacy_sides: tuple[str, ...],
- user_id: Optional[str],
- ) -> None:
- now = datetime.now()
- db.query(PlPlanContract).filter(
- PlPlanContract.plan_id == plan_id,
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- or_(PlPlanContract.stop_id.is_(None), PlPlanContract.stop_id == ""),
- PlPlanContract.side.in_(legacy_sides),
- ).update(
- {"del_flag": 1, "update_by": user_id, "update_time": now},
- synchronize_session=False,
- )
- def update_plan_stop_contracts(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- stop_type: str,
- stop_id: str,
- contracts: list[PlanContractLink],
- user_id: Optional[str],
- dept_id: Optional[str],
- ) -> PlanContractGapRead:
- """保存某装货/送货段的合同关联。"""
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="已作废的计划不能修改")
- stop_type_norm = (stop_type or "").strip().lower()
- stop_id_norm = (stop_id or "").strip()
- if stop_type_norm not in ("load", "delivery") or not stop_id_norm:
- raise HTTPException(status_code=422, detail="stopType 必须为 load 或 delivery,且 stopId 不能为空")
- if stop_type_norm == "load":
- stop = (
- db.query(PlPlanLoad)
- .filter(
- PlPlanLoad.id == stop_id_norm,
- PlPlanLoad.plan_id == plan.id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- )
- .first()
- )
- side = PLAN_CONTRACT_SIDE_LOAD
- legacy_sides = (PLAN_CONTRACT_SIDE_SUPPLIER,)
- side_label = "装货段合同"
- else:
- stop = (
- db.query(PlPlanDelivery)
- .filter(
- PlPlanDelivery.id == stop_id_norm,
- PlPlanDelivery.plan_id == plan.id,
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- )
- .first()
- )
- side = PLAN_CONTRACT_SIDE_DELIVERY
- legacy_sides = (PLAN_CONTRACT_SIDE_DEMANDER,)
- side_label = "送货段合同"
- if not stop:
- raise HTTPException(status_code=404, detail="段次不存在")
- if is_internal_customer_name(stop.customer_name):
- raise HTTPException(status_code=400, detail="内置客户无需补充合同")
- links = _normalize_contract_links(contracts)
- links = assert_at_most_one_contract(links, side_label)
- expected_customer_id = stop.customer_id
- resolved: list[PlanContractLink] = []
- for link in links:
- contract = assert_contract_selectable_for_plan(
- db,
- link.contract_id,
- tenant_no,
- expected_customer_id=expected_customer_id,
- side_label=side_label,
- )
- resolved.append(
- PlanContractLink(
- contractId=contract.id,
- contractNo=contract.contract_no or link.contract_no,
- stopId=stop_id_norm,
- )
- )
- _clear_legacy_side_contracts(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- legacy_sides=legacy_sides,
- user_id=user_id,
- )
- _write_stop_contract_rows(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- stop_id=stop_id_norm,
- side=side,
- contracts=resolved,
- user_id=user_id,
- dept_id=dept_id,
- )
- supplier_contracts, demander_contracts = load_plan_contracts_grouped(
- db, tenant_no, [plan.id]
- ).get(plan.id, ([], []))
- products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
- load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
- plan.id, (None, [])
- )
- return build_plan_contract_gap_read(
- plan,
- supplier_contracts=supplier_contracts,
- demander_contracts=demander_contracts,
- products=products,
- load_read=load_read,
- delivery_reads=delivery_reads,
- )
- def update_plan_side_contracts(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- side: str,
- contracts: list[PlanContractLink],
- user_id: Optional[str],
- dept_id: Optional[str],
- ) -> PlanContractGapRead:
- """单侧保存计划合同关联(旧计划级接口,兼容保留)。"""
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="已作废的计划不能修改")
- if side not in (PLAN_CONTRACT_SIDE_SUPPLIER, PLAN_CONTRACT_SIDE_DEMANDER):
- raise HTTPException(status_code=422, detail="side 必须为 supplier 或 demander")
- current_s, current_d = load_plan_contracts_grouped(db, tenant_no, [plan.id]).get(
- plan.id, ([], [])
- )
- links = _normalize_contract_links(contracts)
- expected_customer_id = (
- plan.supplier_id if side == PLAN_CONTRACT_SIDE_SUPPLIER else plan.customer_id
- )
- side_label = "供方合同" if side == PLAN_CONTRACT_SIDE_SUPPLIER else "需方合同"
- resolved: list[PlanContractLink] = []
- for link in links:
- contract = assert_contract_selectable_for_plan(
- db,
- link.contract_id,
- tenant_no,
- expected_customer_id=expected_customer_id,
- side_label=side_label,
- )
- resolved.append(
- PlanContractLink(
- contractId=contract.id,
- contractNo=contract.contract_no or link.contract_no,
- )
- )
- supplier_links = resolved if side == PLAN_CONTRACT_SIDE_SUPPLIER else current_s
- demander_links = resolved if side == PLAN_CONTRACT_SIDE_DEMANDER else current_d
- replace_plan_contracts(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- supplier_contracts=supplier_links,
- demander_contracts=demander_links,
- user_id=user_id,
- dept_id=dept_id,
- )
- supplier_contracts, demander_contracts = load_plan_contracts_grouped(
- db, tenant_no, [plan.id]
- ).get(plan.id, ([], []))
- products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
- load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
- plan.id, (None, [])
- )
- return build_plan_contract_gap_read(
- plan,
- supplier_contracts=supplier_contracts,
- demander_contracts=demander_contracts,
- products=products,
- load_read=load_read,
- delivery_reads=delivery_reads,
- )
|