"""运输计划 · 装货/送货段读取与列表摘要。""" from __future__ import annotations from collections import defaultdict from datetime import date, datetime from decimal import Decimal from typing import Optional from fastapi import HTTPException from sqlalchemy.orm import Session from app.core.utils import generate_uuid from app.services.pl_mobile_product_identity import resolve_mobile_product_link, is_existing_pending_delivery from app.models.crm_models import CrmContract, CrmContractProduct from app.models.pl_models import ( PlPlan, PlPlanContract, PlPlanDelivery, PlPlanDeliveryProduct, PlPlanLoad, PlPlanLoadProduct, PlTransportAddress, ) from app.schemas.pl.plan import ( PlanArchiveBrandQtyItem, PlanContractLink, PlanDeliveryBody, PlanDeliveryRead, PlanLoadBody, PlanLoadRead, PlanStopProductBody, PlanStopProductRead, ) from app.services.pl_brand_archive import load_brand_qtys_by_stop_product_ids from app.services.pl_internal_customer import is_internal_customer_name from app.services.pl_plan_stop_rules import normalize_stop_dibang_fields, warn_qty_imbalance from app.services.pl_plan_service import ( PLAN_CONTRACT_SIDE_DEMANDER, PLAN_CONTRACT_SIDE_SUPPLIER, assert_contract_selectable_for_plan, belongs_company_trading_entity, ) from app.services.pl_product_display import load_crm_product_alias_maps, resolve_product_display_names PLAN_CONTRACT_SIDE_LOAD = "load" PLAN_CONTRACT_SIDE_DELIVERY = "delivery" def _contract_link(row: PlPlanContract) -> PlanContractLink: return PlanContractLink( contractId=row.contract_id, contractNo=row.contract_no, stopId=row.stop_id, ) def _attach_contract_nos_to_stop_products( db: Session, tenant_no: str, products: list[PlanStopProductRead], contracts: list[PlanContractLink], ) -> None: """段产品行展示用合同编号:优先段合同关联,其次按 contract_id 查 CRM。""" if not products: return by_id = { str(c.contract_id).strip(): (c.contract_no or "").strip() for c in contracts or [] if (c.contract_id or "").strip() } missing: list[str] = [] for product in products: cid = (product.contract_id or "").strip() if not cid: continue no = by_id.get(cid) or (product.contract_no or "").strip() if no: product.contract_no = no by_id.setdefault(cid, no) else: missing.append(cid) need = [cid for cid in dict.fromkeys(missing) if cid not in by_id or not by_id.get(cid)] if not need: return rows = ( db.query(CrmContract.id, CrmContract.contract_no) .filter( CrmContract.tenant_no == tenant_no, CrmContract.del_flag == 0, CrmContract.id.in_(need), ) .all() ) for cid, cno in rows: key = str(cid or "").strip() if key and (cno or "").strip(): by_id[key] = str(cno).strip() for product in products: cid = (product.contract_id or "").strip() if not cid: continue no = by_id.get(cid) if no: product.contract_no = no def _attach_stop_brand_qtys( product: PlanStopProductRead, brand_qtys_by_stop: dict[str, list[dict]], ) -> PlanStopProductRead: raw = brand_qtys_by_stop.get(product.id) or None if raw: product.brand_qtys = [PlanArchiveBrandQtyItem.model_validate(x) for x in raw] else: product.brand_qtys = None return product def _resolve_stop_list_product_name( *, saved_product_name: Optional[str], product_id: Optional[str], standard_by_id: dict[str, str], alias_options_by_id: dict[str, list[str]], ) -> str: """列表展示用:优先 CRM 标准名,不展示别名。""" display_name, _alias = resolve_product_display_names( saved_product_name=saved_product_name, product_id=product_id, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) return display_name or (saved_product_name or "") def _apply_stop_list_product_name( product: PlanStopProductRead, *, standard_by_id: dict[str, str], alias_options_by_id: dict[str, list[str]], ) -> PlanStopProductRead: product.product_name = _resolve_stop_list_product_name( saved_product_name=product.product_name, product_id=product.product_id, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) return product def _load_stop_product( row: PlPlanLoadProduct, brand_qtys_by_stop: dict[str, list[dict]] | None = None, *, standard_by_id: dict[str, str] | None = None, alias_options_by_id: dict[str, list[str]] | None = None, ) -> PlanStopProductRead: product = PlanStopProductRead.model_validate(row) if standard_by_id is not None and alias_options_by_id is not None: product = _apply_stop_list_product_name( product, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) if brand_qtys_by_stop is not None: return _attach_stop_brand_qtys(product, brand_qtys_by_stop) return product def _delivery_stop_product( row: PlPlanDeliveryProduct, brand_qtys_by_stop: dict[str, list[dict]] | None = None, *, standard_by_id: dict[str, str] | None = None, alias_options_by_id: dict[str, list[str]] | None = None, ) -> PlanStopProductRead: product = PlanStopProductRead.model_validate(row) if standard_by_id is not None and alias_options_by_id is not None: product = _apply_stop_list_product_name( product, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) if brand_qtys_by_stop is not None: return _attach_stop_brand_qtys(product, brand_qtys_by_stop) return product def load_stop_contract_rows_grouped( db: Session, tenant_no: str, plan_ids: list[str], ) -> dict[str, list[PlPlanContract]]: if not plan_ids: return {} rows = ( db.query(PlPlanContract) .filter( PlPlanContract.tenant_no == tenant_no, PlPlanContract.plan_id.in_(plan_ids), PlPlanContract.del_flag == 0, ) .order_by(PlPlanContract.order_num.asc(), PlPlanContract.create_time.asc()) .all() ) grouped: dict[str, list[PlPlanContract]] = defaultdict(list) for row in rows: grouped[row.plan_id].append(row) return grouped def _contracts_for_stop( contract_rows: list[PlPlanContract], stop_id: str, legacy_sides: tuple[str, ...], ) -> list[PlanContractLink]: by_stop = [_contract_link(r) for r in contract_rows if (r.stop_id or "") == stop_id] if by_stop: return by_stop return [ _contract_link(r) for r in contract_rows if not (r.stop_id or "").strip() and r.side in legacy_sides ] def _to_decimal(value) -> Optional[Decimal]: if value is None or value == "": return None try: return Decimal(str(value)) except Exception: return None def resolve_stop_address_from_library( db: Session, tenant_no: str, address: Optional[str], lng, lat, ) -> Optional[str]: """段 address 为空时,按经纬度从运输地址库补全 full_address。""" text = (address or "").strip() if text: return text lat_d = _to_decimal(lat) lng_d = _to_decimal(lng) if lat_d is None and lng_d is None: return text or None query = db.query(PlTransportAddress).filter( PlTransportAddress.tenant_no == tenant_no, PlTransportAddress.del_flag == 0, PlTransportAddress.status == "1", ) if lat_d is not None: query = query.filter(PlTransportAddress.lat == lat_d) if lng_d is not None: query = query.filter(PlTransportAddress.lng == lng_d) rows = query.order_by(PlTransportAddress.create_time.desc()).all() if not rows: return text or None if len(rows) == 1: full = (rows[0].full_address or "").strip() return full or None if lng_d is not None: for row in rows: if row.lng == lng_d: full = (row.full_address or "").strip() if full: return full full = (rows[0].full_address or "").strip() return full or None def enrich_stop_addresses_from_library( db: Session, tenant_no: str, load: PlanLoadBody, deliveries: list[PlanDeliveryBody], ) -> None: resolved_load = resolve_stop_address_from_library( db, tenant_no, load.address, load.lng, load.lat ) if resolved_load: load.address = resolved_load for delivery in deliveries: resolved = resolve_stop_address_from_library( db, tenant_no, delivery.address, delivery.lng, delivery.lat ) if resolved: delivery.address = resolved def load_plan_stops_grouped( db: Session, tenant_no: str, plan_ids: list[str], ) -> dict[str, tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]]]: """批量读取装货段与送货段(列表/详情共用)。""" if not plan_ids: return {} loads = ( db.query(PlPlanLoad) .filter( PlPlanLoad.tenant_no == tenant_no, PlPlanLoad.plan_id.in_(plan_ids), PlPlanLoad.del_flag == 0, ) .all() ) load_by_plan = {row.plan_id: row for row in loads} load_ids = [row.id for row in loads] load_products: dict[str, list[PlPlanLoadProduct]] = defaultdict(list) if load_ids: for row in ( db.query(PlPlanLoadProduct) .filter( PlPlanLoadProduct.tenant_no == tenant_no, PlPlanLoadProduct.load_id.in_(load_ids), PlPlanLoadProduct.del_flag == 0, ) .order_by(PlPlanLoadProduct.order_num.asc(), PlPlanLoadProduct.create_time.asc()) .all() ): load_products[row.load_id].append(row) deliveries = ( db.query(PlPlanDelivery) .filter( PlPlanDelivery.tenant_no == tenant_no, PlPlanDelivery.plan_id.in_(plan_ids), PlPlanDelivery.del_flag == 0, ) .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc()) .all() ) deliveries_by_plan: dict[str, list[PlPlanDelivery]] = defaultdict(list) delivery_ids: list[str] = [] for row in deliveries: deliveries_by_plan[row.plan_id].append(row) delivery_ids.append(row.id) delivery_products: dict[str, list[PlPlanDeliveryProduct]] = defaultdict(list) if delivery_ids: for row in ( db.query(PlPlanDeliveryProduct) .filter( PlPlanDeliveryProduct.tenant_no == tenant_no, PlPlanDeliveryProduct.delivery_id.in_(delivery_ids), PlPlanDeliveryProduct.del_flag == 0, ) .order_by(PlPlanDeliveryProduct.order_num.asc(), PlPlanDeliveryProduct.create_time.asc()) .all() ): delivery_products[row.delivery_id].append(row) stop_product_ids: list[str] = [] crm_product_ids: set[str] = set() for rows in load_products.values(): for p in rows: if p.id: stop_product_ids.append(p.id) if (p.product_id or "").strip(): crm_product_ids.add(p.product_id.strip()) for rows in delivery_products.values(): for p in rows: if p.id: stop_product_ids.append(p.id) if (p.product_id or "").strip(): crm_product_ids.add(p.product_id.strip()) brand_qtys_by_stop = load_brand_qtys_by_stop_product_ids( db, tenant_no=tenant_no, stop_product_ids=stop_product_ids ) standard_by_id, alias_options_by_id = load_crm_product_alias_maps( db, tenant_no=tenant_no, product_ids=crm_product_ids ) contracts_by_plan = load_stop_contract_rows_grouped(db, tenant_no, plan_ids) result: dict[str, tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]]] = {} for plan_id in plan_ids: contract_rows = contracts_by_plan.get(plan_id, []) load_row = load_by_plan.get(plan_id) load_read: Optional[PlanLoadRead] = None if load_row: load_read = PlanLoadRead.model_validate(load_row) resolved_load_address = resolve_stop_address_from_library( db, tenant_no, load_read.address, load_read.lng, load_read.lat, ) if resolved_load_address: load_read.address = resolved_load_address load_read.products = [ _load_stop_product( p, brand_qtys_by_stop, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) for p in load_products.get(load_row.id, []) ] load_read.contracts = _contracts_for_stop( contract_rows, load_row.id, (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER), ) _attach_contract_nos_to_stop_products( db, tenant_no, load_read.products, load_read.contracts ) delivery_reads: list[PlanDeliveryRead] = [] plan_deliveries = deliveries_by_plan.get(plan_id, []) for idx, delivery_row in enumerate(plan_deliveries): delivery_read = PlanDeliveryRead.model_validate(delivery_row) resolved_delivery_address = resolve_stop_address_from_library( db, tenant_no, delivery_read.address, delivery_read.lng, delivery_read.lat, ) if resolved_delivery_address: delivery_read.address = resolved_delivery_address delivery_read.products = [ _delivery_stop_product( p, brand_qtys_by_stop, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) for p in delivery_products.get(delivery_row.id, []) ] legacy_sides = (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER) # 未迁移合同仅挂到第一个送货段 if idx == 0: delivery_read.contracts = _contracts_for_stop( contract_rows, delivery_row.id, legacy_sides, ) else: delivery_read.contracts = _contracts_for_stop( contract_rows, delivery_row.id, (PLAN_CONTRACT_SIDE_DELIVERY,), ) _attach_contract_nos_to_stop_products( db, tenant_no, delivery_read.products, delivery_read.contracts ) delivery_reads.append(delivery_read) result[plan_id] = (load_read, delivery_reads) return result def resolve_load_customer_name( plan: PlPlan, load: Optional[PlanLoadRead], ) -> Optional[str]: if load and (load.customer_name or "").strip(): return load.customer_name.strip() legacy = (plan.supplier_name or "").strip() return legacy or None def resolve_delivery_customer_summary( plan: PlPlan, deliveries: list[PlanDeliveryRead], ) -> Optional[str]: names: list[str] = [] seen: set[str] = set() for delivery in deliveries: name = (delivery.customer_name or "").strip() if name and name not in seen: seen.add(name) names.append(name) if names: if len(names) <= 2: return "、".join(names) return f"{names[0]}、{names[1]}等{len(names)}家" legacy = (plan.customer_name or plan.destination_name or "").strip() return legacy or None def collect_unique_delivery_date_strings(deliveries) -> list[str]: """送货段 delivery_time → 去重升序 YYYY-MM-DD。""" days: set[date] = set() for row in deliveries or []: raw = getattr(row, "delivery_time", None) if raw is None or raw == "": continue if isinstance(raw, datetime): days.add(raw.date()) elif isinstance(raw, date): days.add(raw) else: text = str(raw).strip() if not text: continue try: days.add(datetime.strptime(text[:10], "%Y-%m-%d").date()) except ValueError: continue return [d.isoformat() for d in sorted(days)] def attach_plan_stops_to_item( plan_item, plan: PlPlan, stops: tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]], ) -> None: load_read, delivery_reads = stops plan_item.load = load_read plan_item.deliveries = delivery_reads plan_item.load_customer_name = resolve_load_customer_name(plan, load_read) plan_item.delivery_customer_summary = resolve_delivery_customer_summary(plan, delivery_reads) if load_read and (load_read.address or "").strip(): plan_item.pickup_address = load_read.address if delivery_reads and (delivery_reads[0].address or "").strip(): plan_item.destination_address = delivery_reads[0].address plan_item.delivery_times = collect_unique_delivery_date_strings(delivery_reads) def uses_multi_stop_payload(load: Optional[PlanLoadBody], deliveries: Optional[list[PlanDeliveryBody]]) -> bool: return load is not None or deliveries is not None def _normalize_contract_links(links: Optional[list[PlanContractLink]]) -> list[PlanContractLink]: if not links: return [] seen: set[str] = set() out: list[PlanContractLink] = [] for link in links: cid = (link.contract_id or "").strip() if not cid or cid in seen: continue seen.add(cid) out.append(link) return out def assert_at_most_one_contract( links: list[PlanContractLink], side_label: str, ) -> list[PlanContractLink]: if len(links) > 1: raise HTTPException( status_code=422, detail=f"{side_label}只能选择 1 份", ) return links def _assert_product_rows_in_contract_allowed( products: Optional[list], allowed: set[str], *, row_label: str = "装货产品", ) -> None: for prod in products or []: pid = str(getattr(prod, "product_id", None) or "").strip() name = (getattr(prod, "product_name", None) or pid or "未命名").strip() if not pid or pid not in allowed: raise HTTPException( status_code=422, detail=f"{row_label}「{name}」不在所选合同明细中", ) def _contract_product_rows(db: Session, tenant_no: str, contract_id: str) -> list: return ( db.query(CrmContractProduct) .filter( CrmContractProduct.contract_id == contract_id, CrmContractProduct.tenant_no == tenant_no, CrmContractProduct.del_flag == 0, ) .all() ) def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str) -> set[str]: rows = _contract_product_rows(db, tenant_no, contract_id) return { str(r.product_id).strip() for r in rows if getattr(r, "product_id", None) and str(r.product_id).strip() } def _assert_stop_products_package_for_contract( db: Session, tenant_no: str, contract_id: str, stop_products: Optional[list], ) -> None: from app.services.pl_contract_plan_qty import ( validate_stop_products_package_against_contract, ) rows = _contract_product_rows(db, tenant_no, contract_id) ok, err = validate_stop_products_package_against_contract( contract_products=rows, stop_products=stop_products or [], ) if not ok: raise HTTPException(status_code=422, detail=err or "包装规格不符合合同明细") def _union_contract_product_rows( db: Session, tenant_no: str, links: list[PlanContractLink], ) -> list: rows: list = [] seen: set[str] = set() for link in links: cid = (link.contract_id or "").strip() if not cid or cid in seen: continue seen.add(cid) rows.extend(_contract_product_rows(db, tenant_no, cid)) return rows def _assert_stop_products_package_for_union( stop_products: Optional[list], contract_rows: list, ) -> None: from app.services.pl_contract_plan_qty import ( validate_stop_products_package_against_contract, ) ok, err = validate_stop_products_package_against_contract( contract_products=contract_rows, stop_products=stop_products or [], ) if not ok: raise HTTPException(status_code=422, detail=err or "包装规格不符合合同明细") def _assert_stop_products_bound_to_lines( stop_products: Optional[list], contract_rows: list, selected_contract_ids: set[str], *, row_label: str, ) -> None: from app.services.pl_contract_plan_qty import ( contract_line_package_specs, normalize_package_spec, ) by_id = { str(getattr(row, "id", None) or "").strip(): row for row in contract_rows if str(getattr(row, "id", None) or "").strip() } if not by_id: return for prod in stop_products or []: cpid = str(getattr(prod, "contract_product_id", None) or "").strip() if not cpid: continue line = by_id.get(cpid) if not line: name = (getattr(prod, "product_name", None) or cpid).strip() raise HTTPException( status_code=422, detail=f"{row_label}「{name}」不在所选合同明细中", ) line_cid = str(getattr(line, "contract_id", None) or "").strip() if line_cid and selected_contract_ids and line_cid not in selected_contract_ids: name = (getattr(prod, "product_name", None) or cpid).strip() raise HTTPException( status_code=422, detail=f"{row_label}「{name}」不在所选合同明细中", ) line_pid = str(getattr(line, "product_id", None) or "").strip() prod_pid = str(getattr(prod, "product_id", None) or "").strip() if line_pid and prod_pid and line_pid != prod_pid: name = (getattr(prod, "product_name", None) or prod_pid).strip() raise HTTPException( status_code=422, detail=f"{row_label}「{name}」与绑定合同行产品不一致", ) pkg = normalize_package_spec(getattr(prod, "package_spec", None)) specs = contract_line_package_specs(line) if pkg and specs and pkg not in specs: name = (getattr(prod, "product_name", None) or prod_pid).strip() raise HTTPException( status_code=422, detail=f"{row_label}包装规格不在合同明细中:{name}({pkg})", ) def assert_load_products_in_selected_contract( db: Session, tenant_no: str, load: PlanLoadBody, plan_products: Optional[list] = None, ) -> None: if is_internal_customer_name(load.customer_name): return links = _normalize_contract_links(load.contracts) if not links: return rows = _union_contract_product_rows(db, tenant_no, links) allowed = { str(r.product_id).strip() for r in rows if getattr(r, "product_id", None) and str(r.product_id).strip() } _assert_product_rows_in_contract_allowed(load.products, allowed, row_label="装货产品") _assert_product_rows_in_contract_allowed(plan_products, allowed, row_label="装货产品") merged = list(load.products or []) + list(plan_products or []) _assert_stop_products_package_for_union(merged, rows) selected_ids = {(link.contract_id or "").strip() for link in links if (link.contract_id or "").strip()} _assert_stop_products_bound_to_lines( load.products, rows, selected_ids, row_label="装货产品" ) def assert_delivery_products_in_selected_contract( db: Session, tenant_no: str, delivery: PlanDeliveryBody, seq: int, ) -> None: if is_internal_customer_name(delivery.customer_name): return links = _normalize_contract_links(delivery.contracts) if not links: return rows = _union_contract_product_rows(db, tenant_no, links) allowed = { str(r.product_id).strip() for r in rows if getattr(r, "product_id", None) and str(r.product_id).strip() } _assert_product_rows_in_contract_allowed( delivery.products, allowed, row_label=f"送货段#{seq}产品", ) _assert_stop_products_package_for_union(delivery.products, rows) selected_ids = {(link.contract_id or "").strip() for link in links if (link.contract_id or "").strip()} _assert_stop_products_bound_to_lines( delivery.products, rows, selected_ids, row_label=f"送货段#{seq}产品" ) def assert_external_stop_products_have_contract_line( load: PlanLoadBody, deliveries: Optional[list[PlanDeliveryBody]], ) -> None: """正式保存:外部客户已选合同时,每行产品必须绑到当前合同明细。暂存不调用。""" _assert_stop_rows_have_contract_line( load.customer_name, load.contracts, load.products, "装货产品" ) for idx, delivery in enumerate(deliveries or [], start=1): _assert_stop_rows_have_contract_line( delivery.customer_name, delivery.contracts, delivery.products, f"送货段#{idx}产品", ) def _assert_stop_rows_have_contract_line( customer_name: Optional[str], contracts: Optional[list[PlanContractLink]], products: Optional[list], row_label: str, ) -> None: if is_internal_customer_name(customer_name): return selected = { (link.contract_id or "").strip() for link in _normalize_contract_links(contracts) if (link.contract_id or "").strip() } if not selected: return for prod in products or []: contract_id = str(getattr(prod, "contract_id", None) or "").strip() contract_product_id = str(getattr(prod, "contract_product_id", None) or "").strip() if contract_id in selected and contract_product_id: continue name = ( getattr(prod, "product_name", None) or getattr(prod, "product_no", None) or "未命名产品" ) raise HTTPException( status_code=422, detail=f"{row_label}「{str(name).strip()}」没有合同编号,请从当前合同补到原来的产品行后再保存", ) def assert_stop_contract_required( customer_name: Optional[str], contracts: Optional[list[PlanContractLink]], side_label: str, ) -> None: """非内部客户装/送货段保存时必须至少选一份合同。""" if is_internal_customer_name(customer_name): return if not _normalize_contract_links(contracts): raise HTTPException(status_code=422, detail=f"请为{side_label}选择合同") def _resolve_stop_contracts( db: Session, tenant_no: str, customer_id: Optional[str], customer_name: Optional[str], contracts: Optional[list[PlanContractLink]], side_label: str, *, expected_supplier_name: Optional[str] = None, ) -> list[PlanContractLink]: if is_internal_customer_name(customer_name): return [] links = _normalize_contract_links(contracts) if not links: return [] if expected_supplier_name is not None and not expected_supplier_name: raise HTTPException(status_code=422, detail="请先选择归属公司") resolved: list[PlanContractLink] = [] for link in links: contract = assert_contract_selectable_for_plan( db, link.contract_id, tenant_no, expected_customer_id=customer_id, expected_supplier_name=expected_supplier_name or None, side_label=side_label, ) resolved.append( PlanContractLink( contractId=contract.id, contractNo=contract.contract_no or link.contract_no, ) ) return resolved def has_pending_stop_products(db: Session, tenant_no: str, plan_id: str) -> bool: # Uses only pre-existing tables, including when the mobile feature is disabled. for model in (PlPlanLoadProduct, PlPlanDeliveryProduct): if db.query(model.id).filter(model.tenant_no == tenant_no, model.plan_id == plan_id, model.del_flag == 0, model.plan_product_id == "").first(): return True return False def assert_no_pending_stop_products(db: Session, tenant_no: str, plan_id: str) -> None: if has_pending_stop_products(db, tenant_no, plan_id): raise HTTPException(422, '计划存在待关联装货产品,请补齐关联并正式保存后再安排运输') def validate_mobile_stop_sources(db: Session, tenant_no: str, load: PlanLoadBody, deliveries: list[PlanDeliveryBody], products: Optional[list], belongs_company: Optional[str] = None) -> None: """Opt-in source validation also runs for incomplete drafts; never trusts client tenant/line ids.""" from app.models.crm_models import CrmProduct if load is None: raise HTTPException(422, "请填写装货信息") entity = belongs_company_trading_entity(belongs_company) if belongs_company is not None else None product_ids = {str(p.product_id) for p in (products or []) if p.product_id} for stop in [load] + list(deliveries or []): product_ids.update(str(p.product_id) for p in stop.products or [] if p.product_id) links = _normalize_contract_links(stop.contracts) if ( belongs_company is not None and links and not is_internal_customer_name(stop.customer_name) and not entity ): raise HTTPException(422, "请先选择归属公司") # Even internal customers cannot attach an unverified foreign contract. for link in links: assert_contract_selectable_for_plan(db, link.contract_id, tenant_no, expected_customer_id=stop.customer_id, expected_supplier_name=entity if belongs_company is not None and not is_internal_customer_name(stop.customer_name) else None, side_label="移动段合同") contract_rows = _union_contract_product_rows(db, tenant_no, links) if links else [] by_id = {str(row.id): row for row in contract_rows} selected = {str(link.contract_id) for link in links} for product in stop.products or []: cid = (product.contract_id or '').strip() cpid = (product.contract_product_id or '').strip() if not cid and not cpid: continue line = by_id.get(cpid) if not cid or not cpid or cid not in selected or line is None or str(line.contract_id) != cid: raise HTTPException(422, '移动段产品与所选合同明细关联不一致') if str(line.product_id) != str(product.product_id): raise HTTPException(422, '移动段合同行产品不一致') _assert_stop_products_bound_to_lines(stop.products, contract_rows, selected, row_label="移动段产品") if product_ids: found = db.query(CrmProduct.id).filter(CrmProduct.tenant_no == tenant_no, CrmProduct.del_flag == 0, CrmProduct.id.in_(product_ids)).all() if {str(row[0]) for row in found} != product_ids: raise HTTPException(422, '移动段产品不存在或不属于当前租户') def validate_mobile_draft_stops(db: Session, tenant_no: str, load: PlanLoadBody, deliveries: Optional[list[PlanDeliveryBody]], products: Optional[list], belongs_company: Optional[str] = None): rows = list(load.products or []) + [p for stop in deliveries or [] for p in stop.products or []] quantities = [getattr(p, 'plan_qty', None) for p in rows] + [getattr(p, 'quantity', None) for p in products or []] for value in quantities: if value is None: continue qty = Decimal(str(value)) if not qty.is_finite() or qty < 0: raise HTTPException(422, '暂存产品数量须为非负有限数') validate_mobile_stop_sources( db, tenant_no, load, list(deliveries or []), products, belongs_company ) return load, list(deliveries or []) def validate_plan_stops_write( db: Session, tenant_no: str, load: Optional[PlanLoadBody], deliveries: Optional[list[PlanDeliveryBody]], products: Optional[list], belongs_company: Optional[str] = None, ) -> tuple[PlanLoadBody, list[PlanDeliveryBody]]: if load is None: raise HTTPException(status_code=422, detail="请填写装货信息") if not (load.customer_id or "").strip() or not (load.customer_name or "").strip(): raise HTTPException(status_code=422, detail="请选择装货客户") if not deliveries: raise HTTPException(status_code=422, detail="请至少添加一段送货信息") if products is not None and not products: raise HTTPException(status_code=422, detail="请至少保留一条产品明细") assert_stop_contract_required(load.customer_name, load.contracts, "装货客户") entity_name = ( belongs_company_trading_entity(belongs_company) if belongs_company is not None else None ) load.contracts = _resolve_stop_contracts( db, tenant_no, load.customer_id, load.customer_name, load.contracts, "装货合同", expected_supplier_name=entity_name, ) resolved_deliveries: list[PlanDeliveryBody] = [] for idx, delivery in enumerate(deliveries, start=1): if not (delivery.customer_id or "").strip() or not (delivery.customer_name or "").strip(): raise HTTPException(status_code=422, detail=f"送货段#{idx}请选择客户") assert_stop_contract_required(delivery.customer_name, delivery.contracts, f"送货段#{idx}") delivery.contracts = _resolve_stop_contracts( db, tenant_no, delivery.customer_id, delivery.customer_name, delivery.contracts, f"送货段#{idx}合同", expected_supplier_name=entity_name, ) resolved_deliveries.append(delivery) enrich_stop_addresses_from_library(db, tenant_no, load, resolved_deliveries) assert_load_products_in_selected_contract(db, tenant_no, load, plan_products=products) for idx, delivery in enumerate(resolved_deliveries, start=1): assert_delivery_products_in_selected_contract(db, tenant_no, delivery, idx) return load, resolved_deliveries def collect_stop_qty_warnings( products: Optional[list], load: PlanLoadBody, deliveries: list[PlanDeliveryBody], ) -> list[str]: plan_rows = products or [] load_rows = load.products or [] delivery_rows: list[PlanStopProductBody] = [] for delivery in deliveries: delivery_rows.extend(delivery.products or []) return warn_qty_imbalance(plan_rows, load_rows, delivery_rows) def sync_plan_legacy_from_stops( plan: PlPlan, load: PlanLoadBody, deliveries: list[PlanDeliveryBody], ) -> None: plan.supplier_id = load.customer_id plan.supplier_name = load.customer_name plan.pickup_address = load.address plan.pickup_lng = load.lng plan.pickup_lat = load.lat plan.pickup_time = load.load_time plan.pickup_contact = load.contact plan.pickup_notes = load.notes if not deliveries: # Incomplete mobile drafts keep the load while clearing stale legacy customer fields. plan.customer_id = None plan.customer_name = None plan.destination_id = None plan.destination_name = None plan.destination_address = None plan.destination_lng = None plan.destination_lat = None plan.delivery_time = None plan.delivery_contact = None plan.destination_notes = None return first = deliveries[0] plan.customer_id = first.customer_id plan.customer_name = first.customer_name plan.destination_id = first.customer_id plan.destination_name = first.customer_name plan.destination_address = first.address plan.destination_lng = first.lng plan.destination_lat = first.lat plan.delivery_time = first.delivery_time plan.delivery_contact = first.contact plan.destination_notes = first.notes def resolve_delivery_id_for_upsert( *, plan_id: str, payload_id: Optional[str], existing_by_id: dict, ) -> tuple[str, Optional[object]]: pid = (payload_id or "").strip() if not pid: return generate_uuid(), None row = existing_by_id.get(pid) if row is None or getattr(row, "plan_id", None) != plan_id: raise HTTPException(status_code=422, detail=f"送货段 id 无效或不属于本计划:{pid}") return pid, row def _write_stop_contracts( db: Session, tenant_no: str, plan_id: str, stop_id: str, side: str, contracts: Optional[list[PlanContractLink]], user_id: Optional[str], dept_id: Optional[str], order_num_start: int, ) -> int: from app.services.pl_plan_service import sync_plan_contract_scope sync_plan_contract_scope(db, tenant_no=tenant_no, plan_id=plan_id, side=side, stop_id=stop_id, contracts=contracts, user_id=user_id, dept_id=dept_id, order_start=order_num_start) return order_num_start + len(contracts or []) def _resolve_plan_product_id( prod: PlanStopProductBody, plan_products: list, allow_unlinked_draft: bool = False, ) -> str: if getattr(prod, 'unlinked_draft', False): if allow_unlinked_draft and not prod.plan_product_id and not getattr(prod, 'plan_product_client_key', None): return "" raise HTTPException(422, '装货产品尚未匹配完成,请核对装货信息后再正式保存') mobile_id = resolve_mobile_product_link(prod, plan_products) if mobile_id is not None: return mobile_id explicit = (prod.plan_product_id or "").strip() if explicit: for row in plan_products: if row.id == explicit: if row.product_id != prod.product_id: raise HTTPException(422, '段产品与指定计划产品不一致') return explicit raise HTTPException(422, f'计划产品 id 无效或不属于当前计划:{prod.product_name}') product_id = (prod.product_id or "").strip() matches = [row for row in plan_products if product_id and (row.product_id or '').strip() == product_id] product_no = (prod.product_no or "").strip() if not product_id and product_no: matches = [row for row in plan_products if (row.product_no or '').strip() == product_no] if len(matches) == 1: return matches[0].id if len(matches) > 1: raise HTTPException(422, '存在多条相同产品,请回传明确的计划产品 id') raise HTTPException( status_code=422, detail=f"无法匹配计划产品行:{prod.product_name or explicit or product_id}", ) def _resolve_or_create_plan_load( db: Session, tenant_no: str, plan_id: str, load: PlanLoadBody, user_id: Optional[str], dept_id: Optional[str], now: datetime, ) -> str: """装货段与 plan 1:1;uk_pl_plan_load_plan 仅按 plan_id 唯一,软删后不可再 INSERT,须原地更新。""" generate_dibang, dibang_direction = normalize_stop_dibang_fields( load.customer_name, "load", load.generate_dibang, load.dibang_direction, ) existing = ( db.query(PlPlanLoad) .filter( PlPlanLoad.plan_id == plan_id, PlPlanLoad.tenant_no == tenant_no, ) .order_by(PlPlanLoad.del_flag.asc(), PlPlanLoad.update_time.desc()) .first() ) if existing: existing.customer_id = load.customer_id existing.customer_name = load.customer_name existing.address = load.address existing.lng = load.lng existing.lat = load.lat existing.load_time = load.load_time existing.contact = load.contact existing.notes = load.notes existing.generate_dibang = generate_dibang existing.dibang_direction = dibang_direction existing.remark = load.remark existing.del_flag = 0 existing.update_by = user_id existing.update_time = now return existing.id load_id = generate_uuid() db.add( PlPlanLoad( id=load_id, plan_id=plan_id, customer_id=load.customer_id, customer_name=load.customer_name, address=load.address, lng=load.lng, lat=load.lat, load_time=load.load_time, contact=load.contact, notes=load.notes, generate_dibang=generate_dibang, dibang_direction=dibang_direction, tenant_no=tenant_no, remark=load.remark, create_by=user_id, create_dept_id=dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) ) return load_id def assert_plan_create_child_ids(payload): stops = ([payload.load] if payload.load else []) + list(payload.deliveries or []) children = list(payload.products or []) + stops + [row for stop in stops for row in stop.products or []] if any((getattr(row, 'id', None) or '').strip() for row in children): raise HTTPException(422, '新建或复制计划不能携带已有主产品、停靠段或段产品 id') if any((row.plan_product_id or '').strip() for stop in stops for row in stop.products or []): raise HTTPException(422, '新建或复制计划不能携带已有计划产品关联 id') def complete_plan_stops_for_validation(db, tenant_no, plan_id, load, deliveries): """Validate partial edits against stored values without marking omitted fields as submitted.""" def stored_body(row, schema, product_model, fk): data = {field: getattr(row, field) for field in schema.model_fields if hasattr(row, field)} products = db.query(product_model).filter(product_model.tenant_no == tenant_no, product_model.plan_id == plan_id, fk == row.id, product_model.del_flag == 0).all() data['products'] = [PlanStopProductBody(**{ field: getattr(product, field) for field in PlanStopProductBody.model_fields if hasattr(product, field)}) for product in products] contracts = db.query(PlPlanContract).filter(PlPlanContract.tenant_no == tenant_no, PlPlanContract.plan_id == plan_id, PlPlanContract.stop_id == row.id, PlPlanContract.del_flag == 0).all() data['contracts'] = [_contract_link(contract) for contract in contracts] return schema(**data) old_load = db.query(PlPlanLoad).filter(PlPlanLoad.tenant_no == tenant_no, PlPlanLoad.plan_id == plan_id, PlPlanLoad.del_flag == 0).first() full_load = stored_body(old_load, PlanLoadBody, PlPlanLoadProduct, PlPlanLoadProduct.load_id) if old_load else None if load is not None: data = full_load.model_dump() if full_load else {} data.update(load.model_dump(exclude_unset=True)) full_load = PlanLoadBody(**data) old_deliveries = db.query(PlPlanDelivery).filter(PlPlanDelivery.tenant_no == tenant_no, PlPlanDelivery.plan_id == plan_id, PlPlanDelivery.del_flag == 0).all() by_id = {row.id: row for row in old_deliveries} full_deliveries = [] for body in deliveries if deliveries is not None else [PlanDeliveryBody(id=row.id) for row in old_deliveries]: old = by_id.get(body.id) data = stored_body(old, PlanDeliveryBody, PlPlanDeliveryProduct, PlPlanDeliveryProduct.delivery_id).model_dump() if old else {} data.update(body.model_dump(exclude_unset=True)) full_deliveries.append(PlanDeliveryBody(**data)) return full_load, full_deliveries def preflight_plan_child_update(db, tenant_no, plan_id, payload): """Run security/reference checks before the API edits its header or children.""" from types import SimpleNamespace from app.models.pl_models import PlPlanProduct from app.services.pl_child_references import assert_plan_products_removable from app.services.pl_plan_service import prepare_plan_contract_scope, prepare_plan_product_identities existing = db.query(PlPlanProduct).filter(PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.plan_id == plan_id, PlPlanProduct.del_flag == 0).all() projected, removed, identities = existing, [], [] if payload.products is not None: identities, removed = prepare_plan_product_identities(payload.products, existing) projected = [] for product, old, row_id in identities: values = product.model_dump() if old is None else { field: getattr(old, field, getattr(product, field)) for field in type(product).model_fields} values.update(product.model_dump(exclude_unset=True)) values['id'] = row_id values['_mobile_request_client_key'] = getattr(product, 'client_key', None) projected.append(SimpleNamespace(**values)) if old is not None and old.product_id != product.product_id: assert_plan_products_removable(db, tenant_no, plan_id, [old]) jobs, removed_stops = prepare_plan_stops_write(db, tenant_no, plan_id, payload.load, payload.deliveries, projected, allow_unlinked_draft=bool(payload.mobile_product_meta and payload.save_mode == 'draft'), preserve_pending_draft=payload.save_mode == 'draft') ignored = {row.id for job in jobs for row in job[5]} ignored.update(row.id for _, rows, _ in removed_stops for row in rows) assert_plan_products_removable(db, tenant_no, plan_id, removed, ignored) for side in ('supplier', 'demander'): prepare_plan_contract_scope(db, tenant_no=tenant_no, plan_id=plan_id, side=side, contracts=getattr(payload, side + '_contracts')) return ([row_id for _, _, row_id in identities], {id(body): stop_id for _, body, _, stop_id, *_ in jobs}) def _resolve_delivery_identities(db, tenant_no, plan_id, deliveries, existing): """A customer is usable only when unique; child IDs can identify repeated stops.""" from app.services.pl_child_sync import validate_child_ids by_id, explicit_ids = validate_child_ids(deliveries, existing, '送货段') kept, resolved = set(explicit_ids), [] children = db.query(PlPlanDeliveryProduct).filter( PlPlanDeliveryProduct.tenant_no == tenant_no, PlPlanDeliveryProduct.plan_id == plan_id, PlPlanDeliveryProduct.delivery_id.in_(list(by_id)), PlPlanDeliveryProduct.del_flag == 0, ).all() if deliveries and by_id else [] parent_by_child = {row.id: row.delivery_id for row in children} for body in deliveries or []: row = by_id.get((body.id or '').strip()) if row is None: child_ids = [(product.id or '').strip() for product in body.products if product.id] if child_ids: parents = {parent_by_child.get(child_id) for child_id in child_ids} if None in parents or len(parents) != 1: raise HTTPException(422, '送货段子产品 id 无效或属于不同送货段') parent_id = next(iter(parents)) if parent_id in kept: raise HTTPException(422, '送货段缺少 id 且重复引用同一旧段') row = by_id[parent_id] else: candidates = [candidate for candidate in existing if candidate.id not in explicit_ids] customer_id = (body.customer_id or '').strip() if not customer_id and candidates: raise HTTPException(422, '送货段缺少 id 和客户标识,无法确定旧段') candidates = [candidate for candidate in candidates if (candidate.customer_id or '').strip() == customer_id] if customer_id else [] if len(candidates) > 1 or (candidates and candidates[0].id in kept): raise HTTPException(422, '送货段缺少 id 且客户对应多个段,请刷新后回传段 id') if candidates: row = candidates[0] if row is not None: kept.add(row.id) resolved.append((body, row)) for body, row in resolved: if row is not None: body.id = row.id return by_id, kept def prepare_plan_stops_write( db, tenant_no, plan_id, load, deliveries, plan_products, allow_unlinked_draft=False, preserve_pending_draft=False, resolved_stop_ids=None, ): """Resolve all identities and deletion guards before changing any row.""" from app.services.pl_child_sync import validate_child_ids from app.services.pl_child_references import assert_stop_products_removable, assert_stop_removable from app.services.pl_plan_service import prepare_plan_contract_scope existing_load = db.query(PlPlanLoad).filter( PlPlanLoad.tenant_no == tenant_no, PlPlanLoad.plan_id == plan_id, ).order_by(PlPlanLoad.del_flag.asc()).first() existing_deliveries = db.query(PlPlanDelivery).filter( PlPlanDelivery.tenant_no == tenant_no, PlPlanDelivery.plan_id == plan_id, PlPlanDelivery.del_flag == 0).all() delivery_map, kept = _resolve_delivery_identities(db, tenant_no, plan_id, deliveries, existing_deliveries) if load is not None and load.id and ( existing_load is None or load.id != existing_load.id or existing_load.del_flag != 0 ): raise HTTPException(422, '装货段 id 无效或不属于当前计划') jobs, removed_stops = [], [] stops = [] resolved_stop_ids = resolved_stop_ids or {} if load is not None: stops.append(('load', load, existing_load, existing_load.id if existing_load else resolved_stop_ids.get(id(load)) or generate_uuid())) for body in deliveries or []: existing = delivery_map.get((body.id or '').strip()) stops.append(('delivery', body, existing, existing.id if existing else resolved_stop_ids.get(id(body)) or generate_uuid())) for kind, body, existing, stop_id in stops: model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct fk = model.load_id if kind == 'load' else model.delivery_id old = db.query(model).filter(model.tenant_no == tenant_no, model.plan_id == plan_id, fk == stop_id, model.del_flag == 0).all() if existing else [] product_jobs, removed = None, [] if 'products' in body.model_fields_set: by_id, ids = validate_child_ids(body.products, old, '段产品') # Reserve every explicit identity before resolving legacy no-ID rows. # Quantity/name/position are editable values, never identity keys. explicit_ids = set(ids) product_jobs = [] for product in body.products or []: row = by_id.get((product.id or '').strip()) candidates = [candidate for candidate in old if candidate.id not in explicit_ids and candidate.product_id == product.product_id and all((getattr(candidate, field) or '') == (getattr(product, field) or '') for field in ('contract_id', 'contract_product_id') if field in product.model_fields_set)] if row is None else [] if (row is None and not product.plan_product_id and not product.plan_product_client_key and len(candidates) > 1): raise HTTPException(422, '段产品缺少 id 且业务键不唯一,请刷新后回传明细 id') pending_rows = [candidate for candidate in ([row] if row is not None else candidates) if not candidate.plan_product_id] pending = kind == 'delivery' and preserve_pending_draft and is_existing_pending_delivery(product, stop_id, pending_rows) linked = '' if pending else _resolve_plan_product_id(product, plan_products, allow_unlinked_draft) if row is None: matches = [candidate for candidate in candidates if (candidate.plan_product_id or '') == linked] if len(matches) > 1 or (matches and matches[0].id in ids): raise HTTPException(422, '段产品缺少 id 且业务键不唯一,请刷新后回传明细 id') if matches: row = matches[0] ids.add(row.id) if row and (row.plan_product_id != linked or row.product_id != product.product_id or any((getattr(row, field) or '') != (getattr(product, field) or '') for field in ('contract_id', 'contract_product_id') if field in product.model_fields_set)): assert_stop_products_removable(db, tenant_no, plan_id, kind, stop_id, [row]) product_jobs.append((row, product, linked)) removed = [row for row in old if row.id not in ids] assert_stop_products_removable(db, tenant_no, plan_id, kind, stop_id, removed) contracts = prepare_plan_contract_scope( db, tenant_no=tenant_no, plan_id=plan_id, side=kind, stop_id=stop_id, contracts=body.contracts) jobs.append((kind, body, existing, stop_id, product_jobs, removed, contracts)) if deliveries is not None: for stop in existing_deliveries: if stop.id in kept: continue rows = db.query(PlPlanDeliveryProduct).filter( PlPlanDeliveryProduct.tenant_no == tenant_no, PlPlanDeliveryProduct.plan_id == plan_id, PlPlanDeliveryProduct.delivery_id == stop.id, PlPlanDeliveryProduct.del_flag == 0).all() assert_stop_removable(db, tenant_no, plan_id, 'delivery', stop.id, rows) contracts = prepare_plan_contract_scope(db, tenant_no=tenant_no, plan_id=plan_id, side='delivery', stop_id=stop.id, contracts=[]) removed_stops.append((stop, rows, contracts)) return jobs, removed_stops def replace_plan_stops( db: Session, tenant_no: str, plan_id: str, load: Optional[PlanLoadBody], deliveries: Optional[list[PlanDeliveryBody]], user_id: Optional[str], dept_id: Optional[str], plan_products: Optional[list] = None, allow_unlinked_draft: bool = False, preserve_pending_draft: bool = False, resolved_stop_ids: Optional[dict] = None, ) -> None: from app.models.pl_models import PlPlanProduct from app.services.pl_child_sync import apply_keyed_rows if plan_products is None: plan_products = db.query(PlPlanProduct).filter(PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.plan_id == plan_id, PlPlanProduct.del_flag == 0).all() jobs, removed_stops = prepare_plan_stops_write( db, tenant_no, plan_id, load, deliveries, plan_products, allow_unlinked_draft, preserve_pending_draft, resolved_stop_ids) now = datetime.now() common = ('customer_id', 'customer_name', 'address', 'lng', 'lat', 'contact', 'notes', 'remark') product_fields = ('product_id', 'product_no', 'product_name', 'model', 'unit', 'package_spec', 'delivery_method', 'contract_id', 'contract_product_id', 'plan_qty', 'remark') for kind, body, stop, stop_id, product_jobs, removed, contracts in jobs: stop_model = PlPlanLoad if kind == 'load' else PlPlanDelivery if stop is None: # Load has a unique plan_id constraint even for deleted rows. if kind == 'load': stop = db.query(PlPlanLoad).filter(PlPlanLoad.tenant_no == tenant_no, PlPlanLoad.plan_id == plan_id).first() if stop is not None: stop_id = stop.id if stop is None: stop = stop_model(id=stop_id, tenant_no=tenant_no, plan_id=plan_id, create_by=user_id, create_dept_id=dept_id or '', create_time=now) db.add(stop) fields = common + (('load_time',) if kind == 'load' else ('seq', 'delivery_time')) # Submitted parents retain the original full-form scalar semantics. # Child collections below independently distinguish omission from []. for field in fields: setattr(stop, field, getattr(body, field)) if kind == 'delivery': stop.seq = body.seq or 1 stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields( stop.customer_name, kind, body.generate_dibang, body.dibang_direction) stop.del_flag, stop.update_by, stop.update_time = 0, user_id, now model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct for idx, (row, product, linked) in enumerate(product_jobs or []): if row is None: row = model(id=generate_uuid(), tenant_no=tenant_no, plan_id=plan_id, **{('load_id' if kind == 'load' else 'delivery_id'): stop_id}, create_by=user_id, create_dept_id=dept_id or '', create_time=now, del_flag=0) db.add(row) for field in product_fields: if field in product.model_fields_set or row.id is None: setattr(row, field, getattr(product, field)) row.plan_product_id = linked row.order_num, row.update_by, row.update_time = idx, user_id, now for row in removed: row.del_flag, row.update_by, row.update_time = 1, user_id, now if contracts is not None: apply_keyed_rows(db, PlPlanContract, contracts, user_id=user_id, dept_id=dept_id) for stop, rows, contracts in removed_stops: for row in [stop] + rows: row.del_flag, row.update_by, row.update_time = 1, user_id, now apply_keyed_rows(db, PlPlanContract, contracts, user_id=user_id, dept_id=dept_id)