执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。 Co-authored-by: Cursor <cursoragent@cursor.com>
1449 lines
58 KiB
Python
1449 lines
58 KiB
Python
"""运输计划 · 装货/送货段读取与列表摘要。"""
|
||
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):
|
||
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)
|