| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918 |
- """运输计划业务逻辑"""
- import json
- from datetime import date, datetime
- from decimal import Decimal
- from typing import Optional, Union
- from fastapi import HTTPException
- from sqlalchemy import and_, exists, func, or_, select
- from sqlalchemy.orm import Session
- from app.core.utils import generate_uuid
- from app.models.crm_models import CrmProduct
- from app.models.pl_models import (
- PlCarrier,
- PlFreightSettlement,
- PlFreightSettlementTransport,
- PlPersonnel,
- PlPlan,
- PlPlanChange,
- PlPlanContract,
- PlPlanProduct,
- PlPlanTransport,
- PlTransport,
- PlTransportSiteLink,
- PlVehicle,
- PlVehiclePersonnel,
- )
- from app.schemas.pl.plan import (
- PlanArchiveFeedbacksBlock,
- PlanArchiveFreightBlock,
- PlanArchiveProductItem,
- PlanContractLink,
- PlanCreateFull,
- PlanDriverFeedbackUpdateBody,
- PlanProductCreate,
- PlanSiteFeedbackUpdateBody,
- PlanTransportCreateBlock,
- )
- from app.services.pl_brand_archive import persist_plan_product_brand_qtys, validate_brand_qty_rows
- from app.services.pl_feedback_brand_qty import persist_transport_product_feedback
- from app.services.pl_freight_settlement_service import sync_draft_settlement_freight_for_transport
- from app.services.pl_transport_resource_snapshot_service import (
- apply_resource_snapshot_json,
- build_transport_resource_snapshot,
- clear_resource_snapshot,
- )
- def determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
- if destination_name and ("安庆山峡" in destination_name or "安徽云栋" in destination_name):
- return "inbound"
- if supplier_name and ("安庆山峡" in supplier_name or "安徽云栋" in supplier_name):
- return "outbound"
- return "direct"
- def is_ton_product_unit(unit: Optional[str]) -> bool:
- code = (unit or "").strip()
- if not code:
- return True
- return code in ("dun", "吨")
- def _product_quantity(prod: PlanProductCreate) -> Decimal:
- if prod.quantity is not None:
- return Decimal(str(prod.quantity))
- if prod.inbound_estimate_qty is not None:
- return Decimal(str(prod.inbound_estimate_qty))
- if prod.outbound_estimate_qty is not None:
- return Decimal(str(prod.outbound_estimate_qty))
- return Decimal("0")
- def _product_create_actual_qty(prod: PlanProductCreate) -> Decimal:
- if prod.actual_qty is not None:
- return Decimal(str(prod.actual_qty))
- return Decimal("0")
- def _plan_product_actual_qty(prod: PlPlanProduct) -> Decimal:
- if prod.actual_qty is not None:
- return Decimal(str(prod.actual_qty))
- return Decimal("0")
- def sum_product_actual_quantities(products: list[PlanProductCreate]) -> Decimal:
- return sum(_product_create_actual_qty(p) for p in products)
- def sum_plan_product_actual_quantities(products: list[PlPlanProduct]) -> Decimal:
- return sum(_plan_product_actual_qty(p) for p in products)
- def _estimate_qty_fields(plan_type: str, qty: Optional[Decimal]) -> dict:
- if qty is None:
- return {}
- q = float(qty)
- if plan_type == "inbound":
- return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
- if plan_type == "outbound":
- return {"outbound_estimate_qty": q, "inbound_estimate_qty": q}
- return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
- def build_plan_product(
- *,
- tenant_no: str,
- plan_id: str,
- plan_type: str,
- prod: PlanProductCreate,
- order_num: int,
- user_id: str,
- dept_id: str,
- ) -> PlPlanProduct:
- qty = _product_quantity(prod)
- estimates = _estimate_qty_fields(plan_type, qty if qty else None)
- tax_price = prod.tax_price
- return PlPlanProduct(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_id=plan_id,
- product_id=prod.product_id,
- product_no=prod.product_no,
- product_name=prod.product_name,
- brand_mode=prod.brand_mode,
- brand_id=prod.brand_id,
- brand_name=prod.brand_name,
- model=prod.model,
- unit=prod.unit or "吨",
- category_name=prod.category_name,
- package_spec=prod.package_spec,
- delivery_method=prod.delivery_method,
- quantity=qty if qty else None,
- actual_qty=Decimal(str(prod.actual_qty)) if prod.actual_qty is not None else None,
- tax_price=tax_price,
- tax_amount=prod.tax_amount,
- tax_subtotal=prod.tax_subtotal,
- is_explosive=prod.is_explosive,
- is_toxic=prod.is_toxic,
- purchase_cert_url=prod.purchase_cert_url,
- transport_cert_url=prod.transport_cert_url,
- legal_use_cert_url=prod.legal_use_cert_url,
- inbound_price=tax_price,
- outbound_price=tax_price,
- inbound_amount=prod.tax_subtotal,
- outbound_amount=prod.tax_subtotal,
- remark=prod.remark,
- order_num=order_num,
- create_by=user_id,
- create_dept_id=dept_id,
- **estimates,
- )
- def _apply_vehicle_snapshot_to_plan_transport(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- transport: Optional[PlTransport],
- vehicle: PlVehicle,
- driver_id: Optional[str],
- ) -> None:
- snapshot = build_transport_resource_snapshot(
- db,
- tenant_no,
- vehicle.id,
- selected_driver_id=driver_id,
- )
- apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
- def _resolve_driver(
- db: Session,
- tenant_no: str,
- vehicle_id: str,
- driver_id: Optional[str],
- ) -> tuple[Optional[str], Optional[str], Optional[str]]:
- """解析司机:显式选择优先,否则取车辆关联司机;均无则允许为空。"""
- if driver_id:
- personnel = (
- db.query(PlPersonnel)
- .filter(
- PlPersonnel.id == driver_id,
- PlPersonnel.tenant_no == tenant_no,
- PlPersonnel.del_flag == 0,
- )
- .first()
- )
- if personnel:
- return personnel.id, personnel.personnel_name, personnel.phone_no
- default_link = (
- db.query(PlVehiclePersonnel)
- .filter(
- PlVehiclePersonnel.vehicle_id == vehicle_id,
- PlVehiclePersonnel.tenant_no == tenant_no,
- PlVehiclePersonnel.del_flag == 0,
- PlVehiclePersonnel.role == "driver",
- )
- .order_by(PlVehiclePersonnel.is_default.desc(), PlVehiclePersonnel.order_num.asc())
- .first()
- )
- if default_link:
- personnel = (
- db.query(PlPersonnel)
- .filter(
- PlPersonnel.id == default_link.personnel_id,
- PlPersonnel.del_flag == 0,
- )
- .first()
- )
- if personnel:
- return personnel.id, personnel.personnel_name, personnel.phone_no
- return None, None, None
- def _calc_freight_amount(
- freight_type: str,
- products: list[PlPlanProduct],
- transport_qty: Optional[Decimal],
- freight_price: Optional[Decimal],
- freight_amount: Optional[Decimal],
- ) -> Decimal:
- if freight_type == "by_trip":
- return Decimal(str(freight_amount or 0))
- qty = transport_qty
- if qty is None:
- qty = sum_plan_product_actual_quantities(products)
- if freight_price is not None:
- computed = qty * Decimal(str(freight_price))
- if freight_amount is not None:
- return Decimal(str(freight_amount))
- return computed
- return Decimal(str(freight_amount or 0))
- def fetch_hazard_flags_map(
- db: Session,
- tenant_no: str,
- product_ids: Optional[list[str]] = None,
- ) -> dict[str, tuple[int, int]]:
- """批量从 CRM 产品主数据读取易制毒/易制爆标识。"""
- from app.models.crm_models import CrmProduct
- ids = [str(pid) for pid in (product_ids or []) if pid]
- if not ids:
- return {}
- rows = (
- db.query(CrmProduct.id, CrmProduct.is_toxic, CrmProduct.is_explosive)
- .filter(
- CrmProduct.id.in_(ids),
- CrmProduct.tenant_no == tenant_no,
- CrmProduct.del_flag == 0,
- )
- .all()
- )
- return {
- str(row.id): (int(row.is_toxic or 0), int(row.is_explosive or 0))
- for row in rows
- }
- def resolve_hazard_flags_from_crm(
- db: Session,
- tenant_no: str,
- product_id: Optional[str],
- *,
- hazard_map: Optional[dict[str, tuple[int, int]]] = None,
- ) -> tuple[int, int]:
- """从 CRM 产品主数据读取易制毒/易制爆标识(不读计划产品快照)。"""
- if not product_id:
- return 0, 0
- if hazard_map is not None:
- return hazard_map.get(str(product_id), (0, 0))
- from app.models.crm_models import CrmProduct
- row = (
- db.query(CrmProduct)
- .filter(
- CrmProduct.id == product_id,
- CrmProduct.tenant_no == tenant_no,
- CrmProduct.del_flag == 0,
- )
- .first()
- )
- if not row:
- return 0, 0
- return int(row.is_toxic or 0), int(row.is_explosive or 0)
- def plan_has_hazard_products(
- db: Session,
- tenant_no: str,
- products: list[PlPlanProduct],
- ) -> bool:
- return bool(collect_hazard_product_ids(db, tenant_no, products))
- def _cert_url_blank(column):
- """证书 URL 为空(NULL / 空串 / 纯空白)。"""
- return func.nullif(func.trim(func.coalesce(column, "")), "").is_(None)
- def plan_has_hazard_product_exists(tenant_no: str):
- """列表筛选:计划下存在 CRM 标识为易制毒/易制爆的产品。"""
- return exists(
- select(PlPlanProduct.id)
- .select_from(PlPlanProduct)
- .join(
- CrmProduct,
- and_(
- CrmProduct.id == PlPlanProduct.product_id,
- CrmProduct.tenant_no == tenant_no,
- CrmProduct.del_flag == 0,
- ),
- )
- .where(
- PlPlanProduct.plan_id == PlPlan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- or_(CrmProduct.is_toxic == 1, CrmProduct.is_explosive == 1),
- )
- )
- def plan_has_incomplete_hazard_cert_exists(tenant_no: str):
- """列表筛选:存在危化产品且至少一张必需证书未上传。"""
- toxic_incomplete = and_(
- CrmProduct.is_toxic == 1,
- or_(
- _cert_url_blank(PlPlanProduct.purchase_cert_url),
- _cert_url_blank(PlPlanProduct.transport_cert_url),
- ),
- )
- explosive_incomplete = and_(
- CrmProduct.is_explosive == 1,
- _cert_url_blank(PlPlanProduct.legal_use_cert_url),
- )
- return exists(
- select(PlPlanProduct.id)
- .select_from(PlPlanProduct)
- .join(
- CrmProduct,
- and_(
- CrmProduct.id == PlPlanProduct.product_id,
- CrmProduct.tenant_no == tenant_no,
- CrmProduct.del_flag == 0,
- ),
- )
- .where(
- PlPlanProduct.plan_id == PlPlan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- or_(toxic_incomplete, explosive_incomplete),
- )
- )
- def collect_hazard_product_ids(
- db: Session,
- tenant_no: str,
- products: list[Union[PlPlanProduct, PlanProductCreate]],
- ) -> frozenset[str]:
- product_ids = [str(p.product_id) for p in products if getattr(p, "product_id", None)]
- if not product_ids:
- return frozenset()
- hazard_map = fetch_hazard_flags_map(db, tenant_no, product_ids)
- hazard_ids: set[str] = set()
- for prod in products:
- product_id = getattr(prod, "product_id", None)
- if not product_id:
- continue
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- product_id,
- hazard_map=hazard_map,
- )
- if is_toxic or is_explosive:
- hazard_ids.add(str(product_id))
- return frozenset(hazard_ids)
- def clear_hazard_cert_ready(plan: PlPlan) -> None:
- plan.hazard_cert_ready = 0
- plan.hazard_cert_ready_by = None
- plan.hazard_cert_ready_time = None
- def _required_hazard_cert_fields(is_toxic: int, is_explosive: int) -> list[str]:
- fields: list[str] = []
- if is_toxic:
- fields.extend(["purchase_cert_url", "transport_cert_url"])
- if is_explosive:
- fields.append("legal_use_cert_url")
- return fields
- def _is_hazard_product_cert_complete(
- product: PlPlanProduct,
- *,
- is_toxic: int,
- is_explosive: int,
- ) -> bool:
- for field in _required_hazard_cert_fields(is_toxic, is_explosive):
- if not str(getattr(product, field, "") or "").strip():
- return False
- return True
- def all_hazard_products_cert_complete(
- db: Session,
- tenant_no: str,
- products: list[PlPlanProduct],
- ) -> bool:
- """计划内所有危化品产品的所需证书槽位均已上传。"""
- if not products:
- return True
- hazard_map = fetch_hazard_flags_map(
- db,
- tenant_no,
- [product.product_id for product in products if product.product_id],
- )
- has_hazard = False
- for product in products:
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- product.product_id,
- hazard_map=hazard_map,
- )
- if not (is_toxic or is_explosive):
- continue
- has_hazard = True
- if not _is_hazard_product_cert_complete(
- product,
- is_toxic=is_toxic,
- is_explosive=is_explosive,
- ):
- return False
- return has_hazard
- def maybe_auto_set_hazard_cert_ready(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- products: list[PlPlanProduct],
- user_id: str,
- ) -> None:
- """全部危化品证书已上传时,自动标记证书齐备(与人工确认等效)。"""
- if int(plan.hazard_cert_ready or 0):
- return
- if not all_hazard_products_cert_complete(db, tenant_no, products):
- return
- plan.hazard_cert_ready = 1
- plan.hazard_cert_ready_by = user_id
- plan.hazard_cert_ready_time = datetime.now()
- def validate_hazard_cert_ready_for_transport(
- db: Session,
- tenant_no: str,
- plan: PlPlan,
- products: list[PlPlanProduct],
- ) -> None:
- if not plan_has_hazard_products(db, tenant_no, products):
- return
- if int(plan.hazard_cert_ready or 0):
- return
- if all_hazard_products_cert_complete(db, tenant_no, products):
- return
- raise HTTPException(
- status_code=400,
- detail="该计划危化品证书尚未确认齐备,请至危化品证上传页操作",
- )
- def set_hazard_cert_ready(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- ready: bool,
- user_id: str,
- ) -> PlPlan:
- plan = (
- db.query(PlPlan)
- .filter(
- PlPlan.id == plan_id,
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- )
- .first()
- )
- if not plan:
- raise HTTPException(status_code=404, detail="计划不存在")
- if plan.archive_status == "archived":
- raise HTTPException(status_code=400, detail="已归档的计划不能修改证书齐备状态")
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="已作废的计划不能修改证书齐备状态")
- products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == plan_id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- )
- .all()
- )
- has_hazard = plan_has_hazard_products(db, tenant_no, products)
- if ready:
- if not has_hazard:
- raise HTTPException(status_code=400, detail="该计划不含易制毒/易制爆产品,无需确认证书齐备")
- plan.hazard_cert_ready = 1
- plan.hazard_cert_ready_by = user_id
- plan.hazard_cert_ready_time = datetime.now()
- else:
- clear_hazard_cert_ready(plan)
- plan.update_by = user_id
- db.commit()
- db.refresh(plan)
- return plan
- def set_plan_erp_entered(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- entered: bool,
- user_id: str,
- ) -> PlPlan:
- plan = (
- db.query(PlPlan)
- .filter(
- PlPlan.id == plan_id,
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- )
- .first()
- )
- if not plan:
- raise HTTPException(status_code=404, detail="计划不存在")
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="已作废的计划不能修改ERP录入状态")
- plan.erp_entered = 1 if entered else 0
- plan.update_by = user_id
- db.commit()
- db.refresh(plan)
- return plan
- def update_plan_product_certs(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- items: list,
- user_id: str,
- ) -> list[PlPlanProduct]:
- plan = (
- db.query(PlPlan)
- .filter(
- PlPlan.id == plan_id,
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- )
- .first()
- )
- if not plan:
- raise HTTPException(status_code=404, detail="计划不存在")
- if plan.archive_status == "archived":
- raise HTTPException(status_code=400, detail="已归档的计划不能修改")
- existing_rows = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == plan_id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- )
- .all()
- )
- hazard_map = fetch_hazard_flags_map(
- db,
- tenant_no,
- [row.product_id for row in existing_rows if row.product_id],
- )
- updated: list[PlPlanProduct] = []
- for item in items:
- row = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.id == item.id,
- PlPlanProduct.plan_id == plan_id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- )
- .first()
- )
- if not row:
- raise HTTPException(status_code=404, detail=f"计划产品不存在: {item.id}")
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db, tenant_no, row.product_id, hazard_map=hazard_map
- )
- if not (is_toxic or is_explosive):
- raise HTTPException(status_code=400, detail=f"产品【{row.product_name}】无需上传危化品证书")
- row.purchase_cert_url = (item.purchase_cert_url or "").strip() or None
- row.transport_cert_url = (item.transport_cert_url or "").strip() or None
- row.legal_use_cert_url = (item.legal_use_cert_url or "").strip() or None
- row.update_by = user_id
- updated.append(row)
- maybe_auto_set_hazard_cert_ready(
- db,
- tenant_no=tenant_no,
- plan=plan,
- products=existing_rows,
- user_id=user_id,
- )
- plan.update_by = user_id
- db.commit()
- for row in updated:
- db.refresh(row)
- return updated
- def _generate_transport_no(db: Session) -> str:
- now = date.today()
- prefix = f"SXYS{now.strftime('%Y%m')}"
- last = (
- db.query(PlTransport)
- .filter(PlTransport.transport_no.like(f"{prefix}%"))
- .order_by(PlTransport.transport_no.desc())
- .first()
- )
- seq = 1
- if last and last.transport_no:
- try:
- seq = int(last.transport_no[-6:]) + 1
- except ValueError:
- seq = 1
- return f"{prefix}{seq:06d}"
- def _resolve_transport_context(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- transport_payload: PlanTransportCreateBlock,
- products: list[PlPlanProduct],
- ) -> tuple[
- PlVehicle,
- Optional[str],
- Optional[str],
- Optional[str],
- Optional[str],
- Optional[str],
- Decimal,
- Optional[Decimal],
- ]:
- vehicle = (
- db.query(PlVehicle)
- .filter(
- PlVehicle.id == transport_payload.vehicle_id,
- PlVehicle.tenant_no == tenant_no,
- PlVehicle.del_flag == 0,
- )
- .first()
- )
- if not vehicle:
- raise HTTPException(status_code=400, detail="车辆不存在")
- driver_id, driver_name, driver_phone = _resolve_driver(
- db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
- )
- carrier_id = transport_payload.carrier_id or vehicle.carrier_id
- carrier_name = vehicle.carrier_name
- if carrier_id:
- carrier = (
- db.query(PlCarrier)
- .filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
- .first()
- )
- if carrier:
- carrier_name = carrier.carrier_name
- freight_amount = _calc_freight_amount(
- transport_payload.freight_type,
- products,
- transport_payload.transport_qty,
- transport_payload.freight_price,
- transport_payload.freight_amount,
- )
- transport_qty = transport_payload.transport_qty
- if transport_qty is None and transport_payload.freight_type == "by_weight":
- transport_qty = sum_plan_product_actual_quantities(products)
- return (
- vehicle,
- driver_id,
- driver_name,
- driver_phone,
- carrier_id,
- carrier_name,
- freight_amount,
- transport_qty,
- )
- def _apply_payload_to_transport_row(
- transport: PlTransport,
- *,
- vehicle: PlVehicle,
- driver_id: Optional[str],
- driver_name: Optional[str],
- driver_phone: Optional[str],
- carrier_id: Optional[str],
- carrier_name: Optional[str],
- plan: PlPlan,
- transport_payload: PlanTransportCreateBlock,
- freight_amount: Decimal,
- transport_qty: Optional[Decimal],
- user_id: str,
- ) -> None:
- transport.vehicle_id = vehicle.id
- transport.license_plate = vehicle.license_plate
- transport.driver_id = driver_id
- transport.driver_name = driver_name
- transport.driver_phone = driver_phone
- transport.carrier_id = carrier_id
- transport.carrier_name = carrier_name
- transport.start_point = plan.pickup_address
- transport.end_point = plan.destination_address or plan.destination_name
- transport.distance_km = transport_payload.distance_km
- transport.transport_qty = transport_qty
- transport.freight_price = transport_payload.freight_price
- transport.freight_amount = freight_amount or 0
- transport.freight_type = transport_payload.freight_type
- if transport_payload.has_invoice is not None:
- transport.has_invoice = transport_payload.has_invoice
- transport.unit = "吨" if transport_payload.freight_type == "by_weight" else "趟"
- if transport_payload.remark is not None:
- transport.remark = transport_payload.remark
- transport.update_by = user_id
- def upsert_draft_transport_for_plan(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- transport_payload: PlanTransportCreateBlock,
- products: list[PlPlanProduct],
- user_id: str,
- dept_id: str,
- ) -> None:
- """写入/更新草稿运输单(pl_transport + pl_plan_transport),计划保持待安排。"""
- (
- vehicle,
- driver_id,
- driver_name,
- driver_phone,
- carrier_id,
- carrier_name,
- freight_amount,
- transport_qty,
- ) = _resolve_transport_context(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport_payload=transport_payload,
- products=products,
- )
- transport: Optional[PlTransport] = None
- if plan.transport_id:
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.id == plan.transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if transport:
- _apply_payload_to_transport_row(
- transport,
- vehicle=vehicle,
- driver_id=driver_id,
- driver_name=driver_name,
- driver_phone=driver_phone,
- carrier_id=carrier_id,
- carrier_name=carrier_name,
- plan=plan,
- transport_payload=transport_payload,
- freight_amount=freight_amount,
- transport_qty=transport_qty,
- user_id=user_id,
- )
- if transport.transport_status == "draft":
- pass
- else:
- transport_no = _generate_transport_no(db)
- transport = PlTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- transport_no=transport_no,
- transport_status="draft",
- approval_status="none",
- remark=transport_payload.remark,
- create_by=user_id,
- create_dept_id=dept_id,
- )
- db.add(transport)
- db.flush()
- db.add(
- PlPlanTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_id=plan.id,
- transport_id=transport.id,
- create_by=user_id,
- create_dept_id=dept_id,
- )
- )
- plan.transport_id = transport.id
- plan.transport_no = transport_no
- _apply_payload_to_transport_row(
- transport,
- vehicle=vehicle,
- driver_id=driver_id,
- driver_name=driver_name,
- driver_phone=driver_phone,
- carrier_id=carrier_id,
- carrier_name=carrier_name,
- plan=plan,
- transport_payload=transport_payload,
- freight_amount=freight_amount,
- transport_qty=transport_qty,
- user_id=user_id,
- )
- plan.vehicle_id = vehicle.id
- plan.license_plate = vehicle.license_plate
- plan.driver_id = driver_id
- plan.driver_name = driver_name
- _apply_vehicle_snapshot_to_plan_transport(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport=transport,
- vehicle=vehicle,
- driver_id=driver_id,
- )
- plan.freight_amount = freight_amount or 0
- if plan.transport_id:
- sync_draft_settlement_freight_for_transport(
- db,
- tenant_no=tenant_no,
- transport_id=plan.transport_id,
- freight_amount=freight_amount or Decimal("0"),
- user_id=user_id,
- )
- if plan.transport_status != "arranged":
- plan.transport_status = "pending"
- def clear_draft_transport_for_plan(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- user_id: str,
- ) -> None:
- """清除计划上的草稿运输单及车辆预选。"""
- transport: Optional[PlTransport] = None
- if plan.transport_id:
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.id == plan.transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if transport and transport.transport_status == "draft":
- transport.del_flag = 1
- transport.update_by = user_id
- db.query(PlPlanTransport).filter(
- PlPlanTransport.plan_id == plan.id,
- PlPlanTransport.transport_id == transport.id,
- PlPlanTransport.tenant_no == tenant_no,
- PlPlanTransport.del_flag == 0,
- ).update({"del_flag": 1, "update_by": user_id})
- plan.transport_id = None
- plan.transport_no = None
- plan.vehicle_id = None
- plan.license_plate = None
- plan.driver_id = None
- plan.driver_name = None
- clear_resource_snapshot(plan=plan, transport=transport)
- plan.freight_amount = 0
- if plan.transport_status != "arranged":
- plan.transport_status = "pending"
- def sync_transport_prefill_for_plan(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- transport_payload: PlanTransportCreateBlock,
- products: list[PlPlanProduct],
- user_id: str,
- dept_id: str = "",
- ) -> None:
- """同步运输预选到草稿运输子表(pl_transport)。"""
- upsert_draft_transport_for_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport_payload=transport_payload,
- products=products,
- user_id=user_id,
- dept_id=dept_id or user_id,
- )
- def apply_vehicle_prefill_to_plan(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- transport_payload: PlanTransportCreateBlock,
- products: list[PlPlanProduct],
- user_id: str = "",
- dept_id: str = "",
- ) -> None:
- """兼容旧调用:写入草稿运输子表。"""
- upsert_draft_transport_for_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport_payload=transport_payload,
- products=products,
- user_id=user_id,
- dept_id=dept_id or user_id,
- )
- def create_transport_for_plan(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- products: list[PlPlanProduct],
- transport_payload: PlanTransportCreateBlock,
- user_id: str,
- dept_id: str,
- generate_transport_no,
- ) -> PlTransport:
- vehicle = (
- db.query(PlVehicle)
- .filter(
- PlVehicle.id == transport_payload.vehicle_id,
- PlVehicle.tenant_no == tenant_no,
- PlVehicle.del_flag == 0,
- )
- .first()
- )
- if not vehicle:
- raise HTTPException(status_code=400, detail="车辆不存在")
- validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
- driver_id, driver_name, driver_phone = _resolve_driver(
- db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
- )
- carrier_name = vehicle.carrier_name
- carrier_id = transport_payload.carrier_id or vehicle.carrier_id
- if carrier_id:
- carrier = (
- db.query(PlCarrier)
- .filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
- .first()
- )
- if carrier:
- carrier_name = carrier.carrier_name
- freight_amount = _calc_freight_amount(
- transport_payload.freight_type,
- products,
- transport_payload.transport_qty,
- transport_payload.freight_price,
- transport_payload.freight_amount,
- )
- transport_qty = transport_payload.transport_qty
- if transport_qty is None and transport_payload.freight_type == "by_weight":
- transport_qty = sum_plan_product_actual_quantities(products)
- transport_no = generate_transport_no(db)
- transport = PlTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- transport_no=transport_no,
- vehicle_id=vehicle.id,
- license_plate=vehicle.license_plate,
- driver_id=driver_id,
- driver_name=driver_name,
- driver_phone=driver_phone,
- carrier_id=carrier_id,
- carrier_name=carrier_name,
- start_point=plan.pickup_address,
- end_point=plan.destination_address or plan.destination_name,
- distance_km=transport_payload.distance_km,
- unit="吨" if transport_payload.freight_type == "by_weight" else "趟",
- transport_qty=transport_qty,
- freight_price=transport_payload.freight_price,
- freight_amount=freight_amount or 0,
- freight_type=transport_payload.freight_type,
- has_invoice=transport_payload.has_invoice if transport_payload.has_invoice is not None else 0,
- transport_status="draft",
- approval_status="none",
- remark=transport_payload.remark,
- create_by=user_id,
- create_dept_id=dept_id,
- )
- db.add(transport)
- db.add(
- PlPlanTransport(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_id=plan.id,
- transport_id=transport.id,
- create_by=user_id,
- create_dept_id=dept_id,
- )
- )
- plan.transport_id = transport.id
- plan.transport_no = transport_no
- plan.vehicle_id = vehicle.id
- plan.license_plate = vehicle.license_plate
- plan.driver_id = driver_id
- plan.driver_name = driver_name
- _apply_vehicle_snapshot_to_plan_transport(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport=transport,
- vehicle=vehicle,
- driver_id=driver_id,
- )
- plan.transport_status = "arranged"
- plan.follow_status = "arranged"
- plan.freight_amount = freight_amount or 0
- return transport
- PLAN_SELECTABLE_CONTRACT_STATUSES = ("approved", "executing")
- PLAN_CONTRACT_SIDE_SUPPLIER = "supplier"
- PLAN_CONTRACT_SIDE_DEMANDER = "demander"
- def assert_contract_selectable_for_plan(
- db: Session,
- contract_id: str,
- tenant_no: str,
- *,
- expected_customer_id: Optional[str] = None,
- side_label: str = "合同",
- ):
- from app.models.crm_models import CrmContract
- contract = (
- db.query(CrmContract)
- .filter(
- CrmContract.id == contract_id,
- CrmContract.tenant_no == tenant_no,
- CrmContract.del_flag == 0,
- )
- .first()
- )
- if not contract:
- raise HTTPException(status_code=404, detail=f"{side_label}不存在")
- if contract.status not in PLAN_SELECTABLE_CONTRACT_STATUSES:
- raise HTTPException(status_code=422, detail=f"仅已审批或执行中的合同可关联运输计划({side_label})")
- if expected_customer_id and contract.customer_id != expected_customer_id:
- raise HTTPException(status_code=422, detail=f"{side_label}不属于所选客户")
- return contract
- 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 validate_plan_party_contracts(
- db: Session,
- tenant_no: str,
- *,
- supplier_id: Optional[str],
- supplier_name: Optional[str],
- customer_id: Optional[str],
- customer_name: Optional[str],
- supplier_contracts: Optional[list[PlanContractLink]],
- demander_contracts: Optional[list[PlanContractLink]],
- require_products: Optional[list] = None,
- ) -> tuple[list[PlanContractLink], list[PlanContractLink]]:
- if not (supplier_id or "").strip() or not (supplier_name or "").strip():
- raise HTTPException(status_code=422, detail="请选择供方客户")
- if not (customer_id or "").strip() or not (customer_name or "").strip():
- raise HTTPException(status_code=422, detail="请选择需方客户")
- if require_products is not None and not require_products:
- raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
- supplier_links = _normalize_contract_links(supplier_contracts)
- demander_links = _normalize_contract_links(demander_contracts)
- # Local import: pl_plan_stop_service imports this module at top level.
- from app.services.pl_plan_stop_service import assert_at_most_one_contract
- supplier_links = assert_at_most_one_contract(supplier_links, "供方合同")
- demander_links = assert_at_most_one_contract(demander_links, "需方合同")
- resolved_supplier: list[PlanContractLink] = []
- for link in supplier_links:
- contract = assert_contract_selectable_for_plan(
- db,
- link.contract_id,
- tenant_no,
- expected_customer_id=supplier_id,
- side_label="供方合同",
- )
- resolved_supplier.append(
- PlanContractLink(contractId=contract.id, contractNo=contract.contract_no or link.contract_no)
- )
- resolved_demander: list[PlanContractLink] = []
- for link in demander_links:
- contract = assert_contract_selectable_for_plan(
- db,
- link.contract_id,
- tenant_no,
- expected_customer_id=customer_id,
- side_label="需方合同",
- )
- resolved_demander.append(
- PlanContractLink(contractId=contract.id, contractNo=contract.contract_no or link.contract_no)
- )
- return resolved_supplier, resolved_demander
- def replace_plan_contracts(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- supplier_contracts: list[PlanContractLink],
- demander_contracts: list[PlanContractLink],
- user_id: Optional[str],
- dept_id: Optional[str],
- ) -> None:
- now = datetime.now()
- db.query(PlPlanContract).filter(
- PlPlanContract.plan_id == plan_id,
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- ).update(
- {"del_flag": 1, "update_by": user_id, "update_time": now},
- synchronize_session=False,
- )
- rows: list[tuple[str, list[PlanContractLink]]] = [
- (PLAN_CONTRACT_SIDE_SUPPLIER, supplier_contracts),
- (PLAN_CONTRACT_SIDE_DEMANDER, demander_contracts),
- ]
- order_num = 0
- for side, links in rows:
- for link in links:
- order_num += 1
- db.add(
- PlPlanContract(
- id=generate_uuid(),
- plan_id=plan_id,
- side=side,
- contract_id=link.contract_id,
- contract_no=link.contract_no,
- tenant_no=tenant_no,
- order_num=order_num,
- create_by=user_id,
- create_dept_id=dept_id or "",
- create_time=now,
- update_by=user_id,
- update_time=now,
- del_flag=0,
- )
- )
- def load_plan_contracts_grouped(
- db: Session,
- tenant_no: str,
- plan_ids: list[str],
- ) -> dict[str, tuple[list[PlanContractLink], list[PlanContractLink]]]:
- 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()
- )
- buckets: dict[str, list[list[PlanContractLink]]] = {
- pid: [[], []] for pid in plan_ids
- }
- for row in rows:
- bucket = buckets.setdefault(row.plan_id, [[], []])
- link = PlanContractLink(
- contractId=row.contract_id,
- contractNo=row.contract_no,
- stopId=row.stop_id,
- )
- if row.side == PLAN_CONTRACT_SIDE_SUPPLIER:
- bucket[0].append(link)
- elif row.side == PLAN_CONTRACT_SIDE_DEMANDER:
- bucket[1].append(link)
- return {pid: (lists[0], lists[1]) for pid, lists in buckets.items()}
- DRAFTABLE_FOLLOW_STATUSES = frozenset({"drafting", "pending_arrange"})
- def validate_draft_min_fields(*, belongs_company: Optional[str], create_date) -> None:
- if not (belongs_company or "").strip():
- raise HTTPException(status_code=422, detail="请选择归属公司")
- # create_date 允许为空,由 create/update 用 _resolve_create_date 兜底当天
- def resolve_follow_status_on_save(
- *,
- save_mode: str,
- current: Optional[str],
- is_create: bool,
- ) -> str:
- mode = (save_mode or "formal").strip() or "formal"
- if mode == "draft":
- return "drafting"
- if is_create:
- return "pending_arrange"
- if current == "drafting":
- return "pending_arrange"
- return (current or "pending_arrange").strip() or "pending_arrange"
- def assert_can_save_as_draft(current_status: Optional[str]) -> None:
- status = (current_status or "").strip()
- if status not in DRAFTABLE_FOLLOW_STATUSES:
- raise HTTPException(status_code=400, detail="当前状态不可暂存")
- def validate_create_full(payload: PlanCreateFull) -> None:
- if not (payload.supplier_id or "").strip() or not (payload.supplier_name or "").strip():
- raise HTTPException(status_code=422, detail="请选择供方客户")
- if not (payload.customer_id or "").strip() or not (payload.customer_name or "").strip():
- raise HTTPException(status_code=422, detail="请选择需方客户")
- if not payload.products:
- raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
- transport = payload.transport
- if not transport or not transport.vehicle_id:
- return
- if payload.has_freight:
- if transport.freight_type == "by_weight":
- if not transport.freight_price or Decimal(str(transport.freight_price)) <= 0:
- raise HTTPException(status_code=422, detail="按重量计费时请填写运输单价")
- qty = transport.transport_qty or sum_product_actual_quantities(payload.products)
- if qty <= 0:
- raise HTTPException(status_code=422, detail="运输量必须大于0,请填写产品实际数量或运输量")
- elif transport.freight_type == "by_trip":
- if not transport.freight_amount or Decimal(str(transport.freight_amount)) <= 0:
- raise HTTPException(status_code=422, detail="按趟计费时请填写运费金额")
- def calc_plan_total_amount(products: list[PlanProductCreate]) -> Decimal:
- total = Decimal("0")
- for prod in products:
- if prod.tax_subtotal is not None:
- total += Decimal(str(prod.tax_subtotal))
- return total
- _LOCKED_SETTLEMENT_STATUSES = ("approving", "approved", "confirmed")
- def transport_has_locked_freight_settlement(
- db: Session, tenant_no: str, transport_id: Optional[str]
- ) -> bool:
- if not transport_id:
- return False
- return (
- db.query(PlFreightSettlementTransport.id)
- .join(
- PlFreightSettlement,
- PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
- )
- .filter(
- PlFreightSettlementTransport.transport_id == transport_id,
- PlFreightSettlementTransport.tenant_no == tenant_no,
- PlFreightSettlementTransport.del_flag == 0,
- PlFreightSettlement.tenant_no == tenant_no,
- PlFreightSettlement.del_flag == 0,
- PlFreightSettlement.settlement_status.in_(_LOCKED_SETTLEMENT_STATUSES),
- )
- .first()
- is not None
- )
- def sync_archive_qty_from_io_order(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- items: list[PlanArchiveProductItem],
- ) -> list[PlanArchiveProductItem]:
- """若存在入库/出库单,用明细 actual_qty 覆盖归档提交中的实际数量(权威来源)。"""
- from app.models.pl_models import PlInboundOrder, PlInboundOrderItem, PlOutboundOrder, PlOutboundOrderItem
- if plan.plan_type not in ("inbound", "outbound"):
- return items
- header_m = PlInboundOrder if plan.plan_type == "inbound" else PlOutboundOrder
- item_m = PlInboundOrderItem if plan.plan_type == "inbound" else PlOutboundOrderItem
- header = (
- db.query(header_m)
- .filter(
- header_m.tenant_no == tenant_no,
- header_m.plan_id == plan.id,
- header_m.status != "voided",
- header_m.del_flag == 0,
- )
- .order_by(header_m.create_time.desc())
- .first()
- )
- if not header:
- return items
- io_items = (
- db.query(item_m)
- .filter(item_m.order_id == header.id, item_m.del_flag == 0)
- .all()
- )
- qty_by_plan_product = {
- row.plan_product_id: Decimal(str(row.actual_qty))
- for row in io_items
- if row.actual_qty is not None
- }
- if not qty_by_plan_product:
- return items
- out: list[PlanArchiveProductItem] = []
- for item in items:
- qty = qty_by_plan_product.get(item.id)
- if qty is not None:
- out.append(
- PlanArchiveProductItem(
- id=item.id,
- actual_qty=qty,
- inbound_qty=item.inbound_qty,
- inbound_price=item.inbound_price,
- outbound_qty=item.outbound_qty,
- outbound_price=item.outbound_price,
- brand_qtys=item.brand_qtys,
- )
- )
- else:
- out.append(item)
- return out
- def apply_archive_product_quantities(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- items: list[PlanArchiveProductItem],
- user_id: str,
- sync_from_io_order: bool = True,
- ) -> None:
- if sync_from_io_order:
- items = sync_archive_qty_from_io_order(db, tenant_no=tenant_no, plan=plan, items=items)
- product_map = {
- row.id: row
- for row in db.query(PlPlanProduct).filter(
- PlPlanProduct.plan_id == plan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- ).all()
- }
- for item in items:
- prod = product_map.get(item.id)
- if not prod:
- raise HTTPException(status_code=400, detail=f"产品明细不存在: {item.id}")
- actual = (
- Decimal(str(item.actual_qty)) if item.actual_qty is not None else None
- )
- if actual is not None:
- prod.actual_qty = actual
- if item.inbound_qty is not None:
- prod.inbound_qty = Decimal(str(item.inbound_qty))
- elif actual is not None and plan.plan_type == "inbound":
- prod.inbound_qty = actual
- if item.inbound_price is not None:
- prod.inbound_price = Decimal(str(item.inbound_price))
- inbound_qty = prod.inbound_qty
- inbound_price = prod.inbound_price or prod.tax_price
- if inbound_qty is not None and inbound_price is not None:
- prod.inbound_amount = Decimal(str(inbound_qty)) * Decimal(str(inbound_price))
- if item.outbound_qty is not None:
- prod.outbound_qty = Decimal(str(item.outbound_qty))
- elif actual is not None and plan.plan_type == "outbound":
- prod.outbound_qty = actual
- if item.outbound_price is not None:
- prod.outbound_price = Decimal(str(item.outbound_price))
- outbound_qty = prod.outbound_qty
- outbound_price = prod.outbound_price or prod.tax_price
- if outbound_qty is not None and outbound_price is not None:
- prod.outbound_amount = Decimal(str(outbound_qty)) * Decimal(str(outbound_price))
- prod.update_by = user_id
- brand_rows = []
- if item.brand_qtys:
- brand_rows = [
- {
- "brandId": bq.brand_id or "",
- "brandName": bq.brand_name,
- "qty": bq.qty,
- **({"unitPrice": bq.unit_price} if bq.unit_price is not None else {}),
- }
- for bq in item.brand_qtys
- ]
- if actual is not None:
- try:
- validate_brand_qty_rows(
- brand_mode=prod.brand_mode,
- required_brand_id=prod.brand_id,
- actual_qty=actual,
- rows=brand_rows,
- )
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- persist_plan_product_brand_qtys(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- plan_product_id=prod.id,
- brand_mode=prod.brand_mode,
- rows=brand_rows,
- user_id=user_id,
- dept_id=getattr(prod, "create_dept_id", "") or "",
- )
- def apply_archive_brand_qtys_only(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- items: list[PlanArchiveProductItem],
- user_id: str,
- ) -> None:
- """一车多卸:段级数量已写入,此处仅处理计划产品品牌分量。"""
- product_map = {
- row.id: row
- for row in db.query(PlPlanProduct).filter(
- PlPlanProduct.plan_id == plan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- ).all()
- }
- for item in items:
- if not item.brand_qtys:
- continue
- prod = product_map.get(item.id)
- if not prod:
- raise HTTPException(status_code=400, detail=f"产品明细不存在: {item.id}")
- actual = Decimal(str(prod.actual_qty or 0))
- brand_rows = [
- {
- "brandId": bq.brand_id or "",
- "brandName": bq.brand_name,
- "qty": bq.qty,
- **({"unitPrice": bq.unit_price} if bq.unit_price is not None else {}),
- }
- for bq in item.brand_qtys
- ]
- try:
- validate_brand_qty_rows(
- brand_mode=prod.brand_mode,
- required_brand_id=prod.brand_id,
- actual_qty=actual,
- rows=brand_rows,
- )
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- persist_plan_product_brand_qtys(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- plan_product_id=prod.id,
- brand_mode=prod.brand_mode,
- rows=brand_rows,
- user_id=user_id,
- dept_id=getattr(prod, "create_dept_id", "") or "",
- )
- def apply_archive_freight(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- freight: PlanArchiveFreightBlock,
- user_id: str,
- strict: bool = True,
- ) -> None:
- has_freight = int(freight.has_freight if freight.has_freight is not None else plan.has_freight or 0)
- plan.has_freight = has_freight
- if freight.freight_remark is not None:
- plan.freight_remark = freight.freight_remark
- plan.update_by = user_id
- if has_freight != 1:
- plan.freight_amount = Decimal("0")
- if not plan.transport_id:
- return
- if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
- raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.id == plan.transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport:
- raise HTTPException(status_code=400, detail="关联运输单不存在")
- transport.freight_amount = Decimal("0")
- transport.freight_price = None
- transport.transport_qty = None
- transport.update_by = user_id
- sync_draft_settlement_freight_for_transport(
- db,
- tenant_no=tenant_no,
- transport_id=plan.transport_id,
- freight_amount=Decimal("0"),
- user_id=user_id,
- )
- return
- if not plan.transport_id:
- if strict:
- raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
- return
- if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
- raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.id == plan.transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport:
- raise HTTPException(status_code=400, detail="关联运输单不存在")
- transport.distance_km = freight.distance_km
- transport.freight_type = freight.freight_type or "by_weight"
- transport.freight_price = freight.freight_price
- amount = freight.freight_amount or Decimal("0")
- transport.freight_amount = amount
- freight_type = freight.freight_type or transport.freight_type or "by_weight"
- plan_products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == plan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- )
- .all()
- )
- if freight.transport_qty is not None:
- transport.transport_qty = freight.transport_qty
- elif freight_type == "by_weight":
- from app.services.pl_plan_archive_stop_service import sum_delivery_outbound_qty
- from app.models.pl_models import PlPlanLoad
- has_load_stop = (
- db.query(PlPlanLoad.id)
- .filter(
- PlPlanLoad.plan_id == plan.id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- )
- .first()
- )
- if has_load_stop:
- transport.transport_qty = sum_delivery_outbound_qty(
- db, tenant_no=tenant_no, plan_id=plan.id
- )
- else:
- transport.transport_qty = sum_plan_product_actual_quantities(plan_products)
- if freight_type == "by_weight" and (transport.transport_qty or 0) <= 0:
- if strict:
- raise HTTPException(status_code=400, detail="运输量必须大于0,请先填写产品实际数量")
- # 草稿:允许运输量暂缺
- if freight.has_invoice is not None:
- transport.has_invoice = freight.has_invoice
- transport.update_by = user_id
- plan.freight_amount = amount
- sync_draft_settlement_freight_for_transport(
- db,
- tenant_no=tenant_no,
- transport_id=plan.transport_id,
- freight_amount=amount,
- user_id=user_id,
- )
- def record_plan_change(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- change_type: str,
- change_content: str,
- user_id: str,
- user_name: Optional[str],
- dept_id: str,
- ) -> None:
- db.add(
- PlPlanChange(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_id=plan_id,
- change_type=change_type,
- change_content=change_content,
- change_user_id=user_id,
- change_user_name=user_name,
- create_by=user_id,
- create_dept_id=dept_id,
- )
- )
- def apply_plan_driver_feedback(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- payload: PlanDriverFeedbackUpdateBody,
- user_id: str,
- ) -> PlTransport:
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="已作废的计划不能编辑司机回传")
- if not plan.transport_id:
- raise HTTPException(status_code=400, detail="计划未安排运输,无法编辑司机回传")
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.id == plan.transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport:
- raise HTTPException(status_code=404, detail="运输单不存在")
- if payload.products is not None:
- products_payload = [
- {
- "planProductId": item.plan_product_id,
- "actualQty": item.actual_qty,
- "brandQtys": [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])],
- }
- for item in payload.products
- ]
- try:
- persist_transport_product_feedback(
- db,
- tenant_no=tenant_no,
- transport=transport,
- plan_id=plan.id,
- products=products_payload,
- user_id=user_id,
- enforce_brand=True,
- )
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- elif payload.feedback_qty is not None:
- transport.feedback_qty = payload.feedback_qty
- if payload.feedback_remark is not None:
- transport.feedback_remark = payload.feedback_remark.strip() if payload.feedback_remark else None
- if payload.feedback_images is not None:
- transport.feedback_images = json.dumps([item for item in payload.feedback_images if item])
- transport.feedback_time = payload.feedback_time or datetime.now()
- transport.update_by = user_id
- return transport
- def apply_plan_site_feedback(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- link_id: str,
- payload: PlanSiteFeedbackUpdateBody,
- user_id: str,
- ) -> PlTransportSiteLink:
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="已作废的计划不能编辑现场回传")
- link = (
- db.query(PlTransportSiteLink)
- .filter(
- PlTransportSiteLink.id == link_id,
- PlTransportSiteLink.plan_id == plan.id,
- PlTransportSiteLink.tenant_no == tenant_no,
- PlTransportSiteLink.del_flag == 0,
- )
- .first()
- )
- if not link:
- raise HTTPException(status_code=404, detail="现场链接不存在")
- if payload.site_feedback_remark is not None:
- link.site_feedback_remark = (
- payload.site_feedback_remark.strip() if payload.site_feedback_remark else None
- )
- if payload.site_feedback_images is not None:
- link.site_feedback_images = json.dumps([item for item in payload.site_feedback_images if item])
- link.site_feedback_time = payload.site_feedback_time or datetime.now()
- link.update_by = user_id
- return link
- def _resolve_stop_business_type_for_archive(
- db: Session,
- *,
- tenant_no: str,
- plan_id: str,
- stop_type: str,
- stop_id: str,
- ) -> str:
- from app.models.pl_models import PlPlanDelivery, PlPlanLoad
- direction: Optional[str] = None
- if stop_type == "load":
- row = (
- db.query(PlPlanLoad)
- .filter(
- PlPlanLoad.id == stop_id,
- PlPlanLoad.plan_id == plan_id,
- PlPlanLoad.tenant_no == tenant_no,
- PlPlanLoad.del_flag == 0,
- )
- .first()
- )
- direction = (row.dibang_direction or "").strip() if row else None
- else:
- row = (
- db.query(PlPlanDelivery)
- .filter(
- PlPlanDelivery.id == stop_id,
- PlPlanDelivery.plan_id == plan_id,
- PlPlanDelivery.tenant_no == tenant_no,
- PlPlanDelivery.del_flag == 0,
- )
- .first()
- )
- direction = (row.dibang_direction or "").strip() if row else None
- if direction == "outbound":
- return "出库"
- if direction == "inbound":
- return "入库"
- return "出库"
- def apply_archive_stop_feedback_attachments(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- feedbacks: PlanArchiveFeedbacksBlock,
- user_id: str,
- dept_id: str = "",
- ) -> None:
- if not plan.transport_id:
- return
- from app.models.pl_models import PlTransportDriverStopFeedback, PlTransportSiteStopFeedback
- from app.services.pl_transport_stop_feedback_service import (
- save_driver_stop_feedback,
- save_site_stop_feedback,
- )
- transport_id = plan.transport_id
- plan_id = plan.id
- for item in feedbacks.driver_stops or []:
- urls = [url for url in (item.attachment_urls or []) if url]
- existing = (
- db.query(PlTransportDriverStopFeedback)
- .filter(
- PlTransportDriverStopFeedback.transport_id == transport_id,
- PlTransportDriverStopFeedback.tenant_no == tenant_no,
- PlTransportDriverStopFeedback.stop_type == item.stop_type,
- PlTransportDriverStopFeedback.stop_id == item.stop_id,
- PlTransportDriverStopFeedback.del_flag == 0,
- )
- .first()
- )
- if not urls and not existing:
- continue
- save_driver_stop_feedback(
- db,
- tenant_no=tenant_no,
- transport_id=transport_id,
- plan_id=plan_id,
- stop_type=item.stop_type,
- stop_id=item.stop_id,
- attachment_urls=urls,
- user_id=user_id,
- dept_id=dept_id,
- )
- for item in feedbacks.site_stops or []:
- urls = [url for url in (item.attachment_urls or []) if url]
- existing = (
- db.query(PlTransportSiteStopFeedback)
- .filter(
- PlTransportSiteStopFeedback.transport_id == transport_id,
- PlTransportSiteStopFeedback.tenant_no == tenant_no,
- PlTransportSiteStopFeedback.stop_type == item.stop_type,
- PlTransportSiteStopFeedback.stop_id == item.stop_id,
- PlTransportSiteStopFeedback.del_flag == 0,
- )
- .first()
- )
- if not urls and not existing:
- continue
- business_type = _resolve_stop_business_type_for_archive(
- db,
- tenant_no=tenant_no,
- plan_id=plan_id,
- stop_type=item.stop_type,
- stop_id=item.stop_id,
- )
- save_site_stop_feedback(
- db,
- tenant_no=tenant_no,
- transport_id=transport_id,
- plan_id=plan_id,
- stop_type=item.stop_type,
- stop_id=item.stop_id,
- business_type=business_type,
- attachment_urls=urls,
- user_id=user_id,
- dept_id=dept_id,
- require_qty_before_attachments=False,
- )
|