| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060 |
- """运输计划业务逻辑"""
- 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.services.pl_mobile_product_identity import bind_mobile_product_key
- 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,
- product_row_id: Optional[str] = None,
- ) -> PlPlanProduct:
- qty = _product_quantity(prod)
- estimates = _estimate_qty_fields(plan_type, qty if qty else None)
- tax_price = prod.tax_price
- row = PlPlanProduct(
- id=(product_row_id or "").strip() or 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,
- )
- bind_mobile_product_key(row, prod)
- return row
- def apply_plan_product_fields(
- row: PlPlanProduct,
- *,
- plan_type: str,
- prod: PlanProductCreate,
- order_num: int,
- user_id: str,
- ) -> None:
- """就地更新已有计划产品行字段(保持 id),用于 update upsert。"""
- qty = _product_quantity(prod)
- estimates = _estimate_qty_fields(plan_type, qty if qty else None)
- tax_price = prod.tax_price
- row.product_id = prod.product_id
- row.product_no = prod.product_no
- row.product_name = prod.product_name
- row.brand_mode = prod.brand_mode
- row.brand_id = prod.brand_id
- row.brand_name = prod.brand_name
- row.model = prod.model
- row.unit = prod.unit or "吨"
- row.category_name = prod.category_name
- row.package_spec = prod.package_spec
- row.delivery_method = prod.delivery_method
- row.quantity = qty if qty else None
- row.actual_qty = Decimal(str(prod.actual_qty)) if prod.actual_qty is not None else None
- row.tax_price = tax_price
- row.tax_amount = prod.tax_amount
- row.tax_subtotal = prod.tax_subtotal
- row.is_explosive = prod.is_explosive
- row.is_toxic = prod.is_toxic
- row.purchase_cert_url = prod.purchase_cert_url
- row.transport_cert_url = prod.transport_cert_url
- row.legal_use_cert_url = prod.legal_use_cert_url
- row.inbound_price = tax_price
- row.outbound_price = tax_price
- row.inbound_amount = prod.tax_subtotal
- row.outbound_amount = prod.tax_subtotal
- row.remark = prod.remark
- row.order_num = order_num
- row.update_by = user_id
- row.del_flag = 0
- for key, value in estimates.items():
- setattr(row, key, value)
- bind_mobile_product_key(row, prod)
- def resolve_plan_product_id_for_upsert(
- *,
- plan_id: str,
- payload_id: Optional[str],
- existing_by_id: dict,
- ) -> tuple[str, Optional[object]]:
- """解析计划产品行 upsert 主键:有合法 id 则保留;无 id 则新建;非法 id 则 422。"""
- 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 _apply_vehicle_snapshot_to_plan_transport(
- db: Session,
- *,
- tenant_no: str,
- plan: PlPlan,
- transport: Optional[PlTransport],
- vehicle: PlVehicle,
- driver_id: Optional[str],
- escort_id: Optional[str] = None,
- ) -> None:
- snapshot = build_transport_resource_snapshot(
- db,
- tenant_no,
- vehicle.id,
- selected_driver_id=driver_id,
- selected_escort_id=escort_id,
- )
- apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
- def _resolve_personnel_by_vehicle_role(
- db: Session,
- tenant_no: str,
- vehicle_id: str,
- personnel_id: Optional[str],
- *,
- role: str,
- ) -> tuple[Optional[str], Optional[str], Optional[str]]:
- """解析驾押人员:显式选择优先,否则取车辆关联该角色;均无则允许为空。"""
- if personnel_id:
- personnel = (
- db.query(PlPersonnel)
- .filter(
- PlPersonnel.id == personnel_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 == role,
- )
- .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 _resolve_driver(
- db: Session,
- tenant_no: str,
- vehicle_id: str,
- driver_id: Optional[str],
- ) -> tuple[Optional[str], Optional[str], Optional[str]]:
- """解析司机:显式选择优先,否则取车辆关联司机;均无则允许为空。"""
- return _resolve_personnel_by_vehicle_role(
- db, tenant_no, vehicle_id, driver_id, role="driver"
- )
- def _resolve_escort(
- db: Session,
- tenant_no: str,
- vehicle_id: str,
- escort_id: Optional[str],
- ) -> tuple[Optional[str], Optional[str], Optional[str]]:
- """解析押运员:显式选择优先,否则取车辆关联押运;均无则允许为空。"""
- return _resolve_personnel_by_vehicle_role(
- db, tenant_no, vehicle_id, escort_id, role="escort"
- )
- 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="已归档的计划不能修改")
- if plan.follow_status == "cancelled":
- 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],
- 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
- )
- escort_id, escort_name, escort_phone = _resolve_escort(
- db, tenant_no, transport_payload.vehicle_id, transport_payload.escort_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,
- escort_id,
- escort_name,
- escort_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],
- escort_id: Optional[str],
- escort_name: Optional[str],
- escort_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.escort_id = escort_id
- transport.escort_name = escort_name
- transport.escort_phone = escort_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,
- escort_id,
- escort_name,
- escort_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,
- escort_id=escort_id,
- escort_name=escort_name,
- escort_phone=escort_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,
- escort_id=escort_id,
- escort_name=escort_name,
- escort_phone=escort_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
- plan.escort_id = escort_id
- plan.escort_name = escort_name
- plan.escort_phone = escort_phone
- _apply_vehicle_snapshot_to_plan_transport(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport=transport,
- vehicle=vehicle,
- driver_id=driver_id,
- escort_id=escort_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
- plan.escort_id = None
- plan.escort_name = None
- plan.escort_phone = 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
- )
- escort_id, escort_name, escort_phone = _resolve_escort(
- db, tenant_no, transport_payload.vehicle_id, transport_payload.escort_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,
- escort_id=escort_id,
- escort_name=escort_name,
- escort_phone=escort_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
- plan.escort_id = escort_id
- plan.escort_name = escort_name
- plan.escort_phone = escort_phone
- _apply_vehicle_snapshot_to_plan_transport(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport=transport,
- vehicle=vehicle,
- driver_id=driver_id,
- escort_id=escort_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,
- )
|