pl_plan_service.py 67 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091
  1. """运输计划业务逻辑"""
  2. import json
  3. from datetime import date, datetime
  4. from decimal import Decimal
  5. from typing import Optional, Union
  6. from fastapi import HTTPException
  7. from sqlalchemy import and_, exists, func, or_, select
  8. from sqlalchemy.orm import Session
  9. from app.core.utils import generate_uuid
  10. from app.services.pl_mobile_product_identity import bind_mobile_product_key
  11. from app.models.crm_models import CrmProduct
  12. from app.models.pl_models import (
  13. PlCarrier,
  14. PlFreightSettlement,
  15. PlFreightSettlementTransport,
  16. PlPersonnel,
  17. PlPlan,
  18. PlPlanChange,
  19. PlPlanContract,
  20. PlPlanProduct,
  21. PlPlanTransport,
  22. PlTransport,
  23. PlTransportSiteLink,
  24. PlVehicle,
  25. PlVehiclePersonnel,
  26. )
  27. from app.schemas.pl.plan import (
  28. PlanArchiveFeedbacksBlock,
  29. PlanArchiveFreightBlock,
  30. PlanArchiveProductItem,
  31. PlanContractLink,
  32. PlanCreateFull,
  33. PlanDriverFeedbackUpdateBody,
  34. PlanProductCreate,
  35. PlanSiteFeedbackUpdateBody,
  36. PlanTransportCreateBlock,
  37. )
  38. from app.services.pl_brand_archive import persist_plan_product_brand_qtys, validate_brand_qty_rows
  39. from app.services.pl_feedback_brand_qty import persist_transport_product_feedback
  40. from app.services.pl_freight_settlement_service import sync_draft_settlement_freight_for_transport
  41. from app.services.pl_transport_resource_snapshot_service import (
  42. apply_resource_snapshot_json,
  43. build_transport_resource_snapshot,
  44. clear_resource_snapshot,
  45. )
  46. def determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
  47. if destination_name and ("安庆山峡" in destination_name or "安徽云栋" in destination_name):
  48. return "inbound"
  49. if supplier_name and ("安庆山峡" in supplier_name or "安徽云栋" in supplier_name):
  50. return "outbound"
  51. return "direct"
  52. def is_ton_product_unit(unit: Optional[str]) -> bool:
  53. code = (unit or "").strip()
  54. if not code:
  55. return True
  56. return code in ("dun", "吨")
  57. def _product_quantity(prod: PlanProductCreate) -> Decimal:
  58. if prod.quantity is not None:
  59. return Decimal(str(prod.quantity))
  60. if prod.inbound_estimate_qty is not None:
  61. return Decimal(str(prod.inbound_estimate_qty))
  62. if prod.outbound_estimate_qty is not None:
  63. return Decimal(str(prod.outbound_estimate_qty))
  64. return Decimal("0")
  65. def _product_create_actual_qty(prod: PlanProductCreate) -> Decimal:
  66. if prod.actual_qty is not None:
  67. return Decimal(str(prod.actual_qty))
  68. return Decimal("0")
  69. def _plan_product_actual_qty(prod: PlPlanProduct) -> Decimal:
  70. if prod.actual_qty is not None:
  71. return Decimal(str(prod.actual_qty))
  72. return Decimal("0")
  73. def sum_product_actual_quantities(products: list[PlanProductCreate]) -> Decimal:
  74. return sum(_product_create_actual_qty(p) for p in products)
  75. def sum_plan_product_actual_quantities(products: list[PlPlanProduct]) -> Decimal:
  76. return sum(_plan_product_actual_qty(p) for p in products)
  77. def _estimate_qty_fields(plan_type: str, qty: Optional[Decimal]) -> dict:
  78. if qty is None:
  79. return {}
  80. q = float(qty)
  81. if plan_type == "inbound":
  82. return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
  83. if plan_type == "outbound":
  84. return {"outbound_estimate_qty": q, "inbound_estimate_qty": q}
  85. return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
  86. def build_plan_product(
  87. *,
  88. tenant_no: str,
  89. plan_id: str,
  90. plan_type: str,
  91. prod: PlanProductCreate,
  92. order_num: int,
  93. user_id: str,
  94. dept_id: str,
  95. product_row_id: Optional[str] = None,
  96. ) -> PlPlanProduct:
  97. qty = _product_quantity(prod)
  98. estimates = _estimate_qty_fields(plan_type, qty if qty else None)
  99. tax_price = prod.tax_price
  100. row = PlPlanProduct(
  101. id=(product_row_id or "").strip() or generate_uuid(),
  102. tenant_no=tenant_no,
  103. plan_id=plan_id,
  104. product_id=prod.product_id,
  105. product_no=prod.product_no,
  106. product_name=prod.product_name,
  107. brand_mode=prod.brand_mode,
  108. brand_id=prod.brand_id,
  109. brand_name=prod.brand_name,
  110. model=prod.model,
  111. unit=prod.unit or "吨",
  112. category_name=prod.category_name,
  113. package_spec=prod.package_spec,
  114. delivery_method=prod.delivery_method,
  115. quantity=qty if qty else None,
  116. actual_qty=Decimal(str(prod.actual_qty)) if prod.actual_qty is not None else None,
  117. tax_price=tax_price,
  118. tax_amount=prod.tax_amount,
  119. tax_subtotal=prod.tax_subtotal,
  120. is_explosive=prod.is_explosive,
  121. is_toxic=prod.is_toxic,
  122. purchase_cert_url=prod.purchase_cert_url,
  123. transport_cert_url=prod.transport_cert_url,
  124. legal_use_cert_url=prod.legal_use_cert_url,
  125. inbound_price=tax_price,
  126. outbound_price=tax_price,
  127. inbound_amount=prod.tax_subtotal,
  128. outbound_amount=prod.tax_subtotal,
  129. remark=prod.remark,
  130. order_num=order_num,
  131. create_by=user_id,
  132. create_dept_id=dept_id,
  133. **estimates,
  134. )
  135. bind_mobile_product_key(row, prod)
  136. return row
  137. def apply_plan_product_fields(
  138. row: PlPlanProduct,
  139. *,
  140. plan_type: str,
  141. prod: PlanProductCreate,
  142. order_num: int,
  143. user_id: str,
  144. ) -> None:
  145. """只更新提交的字段;计划编辑不覆盖归档数量、单价与金额。"""
  146. values = prod.model_dump(exclude_unset=True, exclude={'id', 'client_key'})
  147. for field, value in values.items():
  148. setattr(row, field, value)
  149. if any(field in values for field in ('quantity', 'inbound_estimate_qty', 'outbound_estimate_qty')):
  150. qty = _product_quantity(prod)
  151. row.quantity = qty
  152. for field, value in _estimate_qty_fields(plan_type, qty).items():
  153. setattr(row, field, value)
  154. row.order_num = order_num
  155. row.update_by = user_id
  156. row.del_flag = 0
  157. bind_mobile_product_key(row, prod)
  158. def prepare_plan_product_identities(products, existing):
  159. """Resolve one request before writes; omitted optional fields cannot change identity."""
  160. from app.services.pl_child_sync import validate_child_ids
  161. by_id, explicit_ids = validate_child_ids(products, existing, '计划产品')
  162. claimed, resolved = set(explicit_ids), []
  163. key_fields = ('model', 'package_spec', 'unit', 'brand_id', 'delivery_method')
  164. for product in products or []:
  165. row = by_id.get((product.id or '').strip())
  166. if row is None:
  167. candidates = [candidate for candidate in existing
  168. if candidate.id not in explicit_ids
  169. and candidate.product_id == product.product_id
  170. and all((getattr(candidate, field, None) or '') == (getattr(product, field) or '')
  171. for field in key_fields if field in product.model_fields_set)]
  172. if len(candidates) > 1 or (candidates and candidates[0].id in claimed):
  173. raise HTTPException(422, '计划产品缺少 id 且业务键不唯一,请刷新后回传明细 id')
  174. if candidates:
  175. row = candidates[0]
  176. claimed.add(row.id)
  177. resolved.append((product, row, row.id if row is not None else generate_uuid()))
  178. # Restore only verified existing identities. New UUIDs stay in the prepared result,
  179. # never masquerading as client-supplied IDs in later validation.
  180. for product, row, _ in resolved:
  181. if row is not None:
  182. product.id = row.id
  183. return resolved, [row for row in existing if row.id not in claimed]
  184. def resolve_plan_product_id_for_upsert(
  185. *,
  186. plan_id: str,
  187. payload_id: Optional[str],
  188. existing_by_id: dict,
  189. ) -> tuple[str, Optional[object]]:
  190. """解析计划产品行 upsert 主键:有合法 id 则保留;无 id 则新建;非法 id 则 422。"""
  191. pid = (payload_id or "").strip()
  192. if not pid:
  193. return generate_uuid(), None
  194. row = existing_by_id.get(pid)
  195. if row is None or getattr(row, "plan_id", None) != plan_id:
  196. raise HTTPException(
  197. status_code=422,
  198. detail=f"计划产品行 id 无效或不属于本计划:{pid}",
  199. )
  200. return pid, row
  201. def _apply_vehicle_snapshot_to_plan_transport(
  202. db: Session,
  203. *,
  204. tenant_no: str,
  205. plan: PlPlan,
  206. transport: Optional[PlTransport],
  207. vehicle: PlVehicle,
  208. driver_id: Optional[str],
  209. escort_id: Optional[str] = None,
  210. ) -> None:
  211. snapshot = build_transport_resource_snapshot(
  212. db,
  213. tenant_no,
  214. vehicle.id,
  215. selected_driver_id=driver_id,
  216. selected_escort_id=escort_id,
  217. )
  218. apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
  219. def _resolve_personnel_by_vehicle_role(
  220. db: Session,
  221. tenant_no: str,
  222. vehicle_id: str,
  223. personnel_id: Optional[str],
  224. *,
  225. role: str,
  226. ) -> tuple[Optional[str], Optional[str], Optional[str]]:
  227. """解析驾押人员:显式选择优先,否则取车辆关联该角色;均无则允许为空。"""
  228. if personnel_id:
  229. personnel = (
  230. db.query(PlPersonnel)
  231. .filter(
  232. PlPersonnel.id == personnel_id,
  233. PlPersonnel.tenant_no == tenant_no,
  234. PlPersonnel.del_flag == 0,
  235. )
  236. .first()
  237. )
  238. if personnel:
  239. return personnel.id, personnel.personnel_name, personnel.phone_no
  240. default_link = (
  241. db.query(PlVehiclePersonnel)
  242. .filter(
  243. PlVehiclePersonnel.vehicle_id == vehicle_id,
  244. PlVehiclePersonnel.tenant_no == tenant_no,
  245. PlVehiclePersonnel.del_flag == 0,
  246. PlVehiclePersonnel.role == role,
  247. )
  248. .order_by(PlVehiclePersonnel.is_default.desc(), PlVehiclePersonnel.order_num.asc())
  249. .first()
  250. )
  251. if default_link:
  252. personnel = (
  253. db.query(PlPersonnel)
  254. .filter(
  255. PlPersonnel.id == default_link.personnel_id,
  256. PlPersonnel.del_flag == 0,
  257. )
  258. .first()
  259. )
  260. if personnel:
  261. return personnel.id, personnel.personnel_name, personnel.phone_no
  262. return None, None, None
  263. def _resolve_driver(
  264. db: Session,
  265. tenant_no: str,
  266. vehicle_id: str,
  267. driver_id: Optional[str],
  268. ) -> tuple[Optional[str], Optional[str], Optional[str]]:
  269. """解析司机:显式选择优先,否则取车辆关联司机;均无则允许为空。"""
  270. return _resolve_personnel_by_vehicle_role(
  271. db, tenant_no, vehicle_id, driver_id, role="driver"
  272. )
  273. def _resolve_escort(
  274. db: Session,
  275. tenant_no: str,
  276. vehicle_id: str,
  277. escort_id: Optional[str],
  278. ) -> tuple[Optional[str], Optional[str], Optional[str]]:
  279. """解析押运员:显式选择优先,否则取车辆关联押运;均无则允许为空。"""
  280. return _resolve_personnel_by_vehicle_role(
  281. db, tenant_no, vehicle_id, escort_id, role="escort"
  282. )
  283. def _calc_freight_amount(
  284. freight_type: str,
  285. products: list[PlPlanProduct],
  286. transport_qty: Optional[Decimal],
  287. freight_price: Optional[Decimal],
  288. freight_amount: Optional[Decimal],
  289. ) -> Decimal:
  290. if freight_type == "by_trip":
  291. return Decimal(str(freight_amount or 0))
  292. qty = transport_qty
  293. if qty is None:
  294. qty = sum_plan_product_actual_quantities(products)
  295. if freight_price is not None:
  296. computed = qty * Decimal(str(freight_price))
  297. if freight_amount is not None:
  298. return Decimal(str(freight_amount))
  299. return computed
  300. return Decimal(str(freight_amount or 0))
  301. def fetch_hazard_flags_map(
  302. db: Session,
  303. tenant_no: str,
  304. product_ids: Optional[list[str]] = None,
  305. ) -> dict[str, tuple[int, int]]:
  306. """批量从 CRM 产品主数据读取易制毒/易制爆标识。"""
  307. from app.models.crm_models import CrmProduct
  308. ids = [str(pid) for pid in (product_ids or []) if pid]
  309. if not ids:
  310. return {}
  311. rows = (
  312. db.query(CrmProduct.id, CrmProduct.is_toxic, CrmProduct.is_explosive)
  313. .filter(
  314. CrmProduct.id.in_(ids),
  315. CrmProduct.tenant_no == tenant_no,
  316. CrmProduct.del_flag == 0,
  317. )
  318. .all()
  319. )
  320. return {
  321. str(row.id): (int(row.is_toxic or 0), int(row.is_explosive or 0))
  322. for row in rows
  323. }
  324. def resolve_hazard_flags_from_crm(
  325. db: Session,
  326. tenant_no: str,
  327. product_id: Optional[str],
  328. *,
  329. hazard_map: Optional[dict[str, tuple[int, int]]] = None,
  330. ) -> tuple[int, int]:
  331. """从 CRM 产品主数据读取易制毒/易制爆标识(不读计划产品快照)。"""
  332. if not product_id:
  333. return 0, 0
  334. if hazard_map is not None:
  335. return hazard_map.get(str(product_id), (0, 0))
  336. from app.models.crm_models import CrmProduct
  337. row = (
  338. db.query(CrmProduct)
  339. .filter(
  340. CrmProduct.id == product_id,
  341. CrmProduct.tenant_no == tenant_no,
  342. CrmProduct.del_flag == 0,
  343. )
  344. .first()
  345. )
  346. if not row:
  347. return 0, 0
  348. return int(row.is_toxic or 0), int(row.is_explosive or 0)
  349. def plan_has_hazard_products(
  350. db: Session,
  351. tenant_no: str,
  352. products: list[PlPlanProduct],
  353. ) -> bool:
  354. return bool(collect_hazard_product_ids(db, tenant_no, products))
  355. def _cert_url_blank(column):
  356. """证书 URL 为空(NULL / 空串 / 纯空白)。"""
  357. return func.nullif(func.trim(func.coalesce(column, "")), "").is_(None)
  358. def plan_has_hazard_product_exists(tenant_no: str):
  359. """列表筛选:计划下存在 CRM 标识为易制毒/易制爆的产品。"""
  360. return exists(
  361. select(PlPlanProduct.id)
  362. .select_from(PlPlanProduct)
  363. .join(
  364. CrmProduct,
  365. and_(
  366. CrmProduct.id == PlPlanProduct.product_id,
  367. CrmProduct.tenant_no == tenant_no,
  368. CrmProduct.del_flag == 0,
  369. ),
  370. )
  371. .where(
  372. PlPlanProduct.plan_id == PlPlan.id,
  373. PlPlanProduct.tenant_no == tenant_no,
  374. PlPlanProduct.del_flag == 0,
  375. or_(CrmProduct.is_toxic == 1, CrmProduct.is_explosive == 1),
  376. )
  377. )
  378. def plan_has_incomplete_hazard_cert_exists(tenant_no: str):
  379. """列表筛选:存在危化产品且至少一张必需证书未上传。"""
  380. toxic_incomplete = and_(
  381. CrmProduct.is_toxic == 1,
  382. or_(
  383. _cert_url_blank(PlPlanProduct.purchase_cert_url),
  384. _cert_url_blank(PlPlanProduct.transport_cert_url),
  385. ),
  386. )
  387. explosive_incomplete = and_(
  388. CrmProduct.is_explosive == 1,
  389. _cert_url_blank(PlPlanProduct.legal_use_cert_url),
  390. )
  391. return exists(
  392. select(PlPlanProduct.id)
  393. .select_from(PlPlanProduct)
  394. .join(
  395. CrmProduct,
  396. and_(
  397. CrmProduct.id == PlPlanProduct.product_id,
  398. CrmProduct.tenant_no == tenant_no,
  399. CrmProduct.del_flag == 0,
  400. ),
  401. )
  402. .where(
  403. PlPlanProduct.plan_id == PlPlan.id,
  404. PlPlanProduct.tenant_no == tenant_no,
  405. PlPlanProduct.del_flag == 0,
  406. or_(toxic_incomplete, explosive_incomplete),
  407. )
  408. )
  409. def collect_hazard_product_ids(
  410. db: Session,
  411. tenant_no: str,
  412. products: list[Union[PlPlanProduct, PlanProductCreate]],
  413. ) -> frozenset[str]:
  414. product_ids = [str(p.product_id) for p in products if getattr(p, "product_id", None)]
  415. if not product_ids:
  416. return frozenset()
  417. hazard_map = fetch_hazard_flags_map(db, tenant_no, product_ids)
  418. hazard_ids: set[str] = set()
  419. for prod in products:
  420. product_id = getattr(prod, "product_id", None)
  421. if not product_id:
  422. continue
  423. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  424. db,
  425. tenant_no,
  426. product_id,
  427. hazard_map=hazard_map,
  428. )
  429. if is_toxic or is_explosive:
  430. hazard_ids.add(str(product_id))
  431. return frozenset(hazard_ids)
  432. def clear_hazard_cert_ready(plan: PlPlan) -> None:
  433. plan.hazard_cert_ready = 0
  434. plan.hazard_cert_ready_by = None
  435. plan.hazard_cert_ready_time = None
  436. def _required_hazard_cert_fields(is_toxic: int, is_explosive: int) -> list[str]:
  437. fields: list[str] = []
  438. if is_toxic:
  439. fields.extend(["purchase_cert_url", "transport_cert_url"])
  440. if is_explosive:
  441. fields.append("legal_use_cert_url")
  442. return fields
  443. def _is_hazard_product_cert_complete(
  444. product: PlPlanProduct,
  445. *,
  446. is_toxic: int,
  447. is_explosive: int,
  448. ) -> bool:
  449. for field in _required_hazard_cert_fields(is_toxic, is_explosive):
  450. if not str(getattr(product, field, "") or "").strip():
  451. return False
  452. return True
  453. def all_hazard_products_cert_complete(
  454. db: Session,
  455. tenant_no: str,
  456. products: list[PlPlanProduct],
  457. ) -> bool:
  458. """计划内所有危化品产品的所需证书槽位均已上传。"""
  459. if not products:
  460. return True
  461. hazard_map = fetch_hazard_flags_map(
  462. db,
  463. tenant_no,
  464. [product.product_id for product in products if product.product_id],
  465. )
  466. has_hazard = False
  467. for product in products:
  468. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  469. db,
  470. tenant_no,
  471. product.product_id,
  472. hazard_map=hazard_map,
  473. )
  474. if not (is_toxic or is_explosive):
  475. continue
  476. has_hazard = True
  477. if not _is_hazard_product_cert_complete(
  478. product,
  479. is_toxic=is_toxic,
  480. is_explosive=is_explosive,
  481. ):
  482. return False
  483. return has_hazard
  484. def maybe_auto_set_hazard_cert_ready(
  485. db: Session,
  486. *,
  487. tenant_no: str,
  488. plan: PlPlan,
  489. products: list[PlPlanProduct],
  490. user_id: str,
  491. ) -> None:
  492. """全部危化品证书已上传时,自动标记证书齐备(与人工确认等效)。"""
  493. if int(plan.hazard_cert_ready or 0):
  494. return
  495. if not all_hazard_products_cert_complete(db, tenant_no, products):
  496. return
  497. plan.hazard_cert_ready = 1
  498. plan.hazard_cert_ready_by = user_id
  499. plan.hazard_cert_ready_time = datetime.now()
  500. def validate_hazard_cert_ready_for_transport(
  501. db: Session,
  502. tenant_no: str,
  503. plan: PlPlan,
  504. products: list[PlPlanProduct],
  505. ) -> None:
  506. if not plan_has_hazard_products(db, tenant_no, products):
  507. return
  508. if int(plan.hazard_cert_ready or 0):
  509. return
  510. if all_hazard_products_cert_complete(db, tenant_no, products):
  511. return
  512. raise HTTPException(
  513. status_code=400,
  514. detail="该计划危化品证书尚未确认齐备,请至危化品证上传页操作",
  515. )
  516. def set_hazard_cert_ready(
  517. db: Session,
  518. *,
  519. tenant_no: str,
  520. plan_id: str,
  521. ready: bool,
  522. user_id: str,
  523. ) -> PlPlan:
  524. plan = (
  525. db.query(PlPlan)
  526. .filter(
  527. PlPlan.id == plan_id,
  528. PlPlan.tenant_no == tenant_no,
  529. PlPlan.del_flag == 0,
  530. )
  531. .first()
  532. )
  533. if not plan:
  534. raise HTTPException(status_code=404, detail="计划不存在")
  535. if plan.archive_status == "archived":
  536. raise HTTPException(status_code=400, detail="已归档的计划不能修改证书齐备状态")
  537. if plan.follow_status == "cancelled":
  538. raise HTTPException(status_code=400, detail="已作废的计划不能修改证书齐备状态")
  539. products = (
  540. db.query(PlPlanProduct)
  541. .filter(
  542. PlPlanProduct.plan_id == plan_id,
  543. PlPlanProduct.tenant_no == tenant_no,
  544. PlPlanProduct.del_flag == 0,
  545. )
  546. .all()
  547. )
  548. has_hazard = plan_has_hazard_products(db, tenant_no, products)
  549. if ready:
  550. if not has_hazard:
  551. raise HTTPException(status_code=400, detail="该计划不含易制毒/易制爆产品,无需确认证书齐备")
  552. plan.hazard_cert_ready = 1
  553. plan.hazard_cert_ready_by = user_id
  554. plan.hazard_cert_ready_time = datetime.now()
  555. else:
  556. clear_hazard_cert_ready(plan)
  557. plan.update_by = user_id
  558. db.commit()
  559. db.refresh(plan)
  560. return plan
  561. def set_plan_erp_entered(
  562. db: Session,
  563. *,
  564. tenant_no: str,
  565. plan_id: str,
  566. entered: bool,
  567. user_id: str,
  568. ) -> PlPlan:
  569. plan = (
  570. db.query(PlPlan)
  571. .filter(
  572. PlPlan.id == plan_id,
  573. PlPlan.tenant_no == tenant_no,
  574. PlPlan.del_flag == 0,
  575. )
  576. .first()
  577. )
  578. if not plan:
  579. raise HTTPException(status_code=404, detail="计划不存在")
  580. if plan.follow_status == "cancelled":
  581. raise HTTPException(status_code=400, detail="已作废的计划不能修改ERP录入状态")
  582. plan.erp_entered = 1 if entered else 0
  583. plan.update_by = user_id
  584. db.commit()
  585. db.refresh(plan)
  586. return plan
  587. def update_plan_product_certs(
  588. db: Session,
  589. *,
  590. tenant_no: str,
  591. plan_id: str,
  592. items: list,
  593. user_id: str,
  594. ) -> list[PlPlanProduct]:
  595. plan = (
  596. db.query(PlPlan)
  597. .filter(
  598. PlPlan.id == plan_id,
  599. PlPlan.tenant_no == tenant_no,
  600. PlPlan.del_flag == 0,
  601. )
  602. .first()
  603. )
  604. if not plan:
  605. raise HTTPException(status_code=404, detail="计划不存在")
  606. if plan.archive_status == "archived":
  607. raise HTTPException(status_code=400, detail="已归档的计划不能修改")
  608. if plan.follow_status == "cancelled":
  609. raise HTTPException(status_code=400, detail="已作废的计划不能修改危化品证书")
  610. existing_rows = (
  611. db.query(PlPlanProduct)
  612. .filter(
  613. PlPlanProduct.plan_id == plan_id,
  614. PlPlanProduct.tenant_no == tenant_no,
  615. PlPlanProduct.del_flag == 0,
  616. )
  617. .all()
  618. )
  619. hazard_map = fetch_hazard_flags_map(
  620. db,
  621. tenant_no,
  622. [row.product_id for row in existing_rows if row.product_id],
  623. )
  624. updated: list[PlPlanProduct] = []
  625. for item in items:
  626. row = (
  627. db.query(PlPlanProduct)
  628. .filter(
  629. PlPlanProduct.id == item.id,
  630. PlPlanProduct.plan_id == plan_id,
  631. PlPlanProduct.tenant_no == tenant_no,
  632. PlPlanProduct.del_flag == 0,
  633. )
  634. .first()
  635. )
  636. if not row:
  637. raise HTTPException(status_code=404, detail=f"计划产品不存在: {item.id}")
  638. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  639. db, tenant_no, row.product_id, hazard_map=hazard_map
  640. )
  641. if not (is_toxic or is_explosive):
  642. raise HTTPException(status_code=400, detail=f"产品【{row.product_name}】无需上传危化品证书")
  643. row.purchase_cert_url = (item.purchase_cert_url or "").strip() or None
  644. row.transport_cert_url = (item.transport_cert_url or "").strip() or None
  645. row.legal_use_cert_url = (item.legal_use_cert_url or "").strip() or None
  646. row.update_by = user_id
  647. updated.append(row)
  648. maybe_auto_set_hazard_cert_ready(
  649. db,
  650. tenant_no=tenant_no,
  651. plan=plan,
  652. products=existing_rows,
  653. user_id=user_id,
  654. )
  655. plan.update_by = user_id
  656. db.commit()
  657. for row in updated:
  658. db.refresh(row)
  659. return updated
  660. def _generate_transport_no(db: Session) -> str:
  661. now = date.today()
  662. prefix = f"SXYS{now.strftime('%Y%m')}"
  663. last = (
  664. db.query(PlTransport)
  665. .filter(PlTransport.transport_no.like(f"{prefix}%"))
  666. .order_by(PlTransport.transport_no.desc())
  667. .first()
  668. )
  669. seq = 1
  670. if last and last.transport_no:
  671. try:
  672. seq = int(last.transport_no[-6:]) + 1
  673. except ValueError:
  674. seq = 1
  675. return f"{prefix}{seq:06d}"
  676. def _resolve_transport_context(
  677. db: Session,
  678. *,
  679. tenant_no: str,
  680. plan: PlPlan,
  681. transport_payload: PlanTransportCreateBlock,
  682. products: list[PlPlanProduct],
  683. ) -> tuple[
  684. PlVehicle,
  685. Optional[str],
  686. Optional[str],
  687. Optional[str],
  688. Optional[str],
  689. Optional[str],
  690. Optional[str],
  691. Optional[str],
  692. Optional[str],
  693. Decimal,
  694. Optional[Decimal],
  695. ]:
  696. vehicle = (
  697. db.query(PlVehicle)
  698. .filter(
  699. PlVehicle.id == transport_payload.vehicle_id,
  700. PlVehicle.tenant_no == tenant_no,
  701. PlVehicle.del_flag == 0,
  702. )
  703. .first()
  704. )
  705. if not vehicle:
  706. raise HTTPException(status_code=400, detail="车辆不存在")
  707. driver_id, driver_name, driver_phone = _resolve_driver(
  708. db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
  709. )
  710. escort_id, escort_name, escort_phone = _resolve_escort(
  711. db, tenant_no, transport_payload.vehicle_id, transport_payload.escort_id
  712. )
  713. carrier_id = transport_payload.carrier_id or vehicle.carrier_id
  714. carrier_name = vehicle.carrier_name
  715. if carrier_id:
  716. carrier = (
  717. db.query(PlCarrier)
  718. .filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
  719. .first()
  720. )
  721. if carrier:
  722. carrier_name = carrier.carrier_name
  723. freight_amount = _calc_freight_amount(
  724. transport_payload.freight_type,
  725. products,
  726. transport_payload.transport_qty,
  727. transport_payload.freight_price,
  728. transport_payload.freight_amount,
  729. )
  730. transport_qty = transport_payload.transport_qty
  731. if transport_qty is None and transport_payload.freight_type == "by_weight":
  732. transport_qty = sum_plan_product_actual_quantities(products)
  733. return (
  734. vehicle,
  735. driver_id,
  736. driver_name,
  737. driver_phone,
  738. escort_id,
  739. escort_name,
  740. escort_phone,
  741. carrier_id,
  742. carrier_name,
  743. freight_amount,
  744. transport_qty,
  745. )
  746. def _apply_payload_to_transport_row(
  747. transport: PlTransport,
  748. *,
  749. vehicle: PlVehicle,
  750. driver_id: Optional[str],
  751. driver_name: Optional[str],
  752. driver_phone: Optional[str],
  753. escort_id: Optional[str],
  754. escort_name: Optional[str],
  755. escort_phone: Optional[str],
  756. carrier_id: Optional[str],
  757. carrier_name: Optional[str],
  758. plan: PlPlan,
  759. transport_payload: PlanTransportCreateBlock,
  760. freight_amount: Decimal,
  761. transport_qty: Optional[Decimal],
  762. user_id: str,
  763. ) -> None:
  764. transport.vehicle_id = vehicle.id
  765. transport.license_plate = vehicle.license_plate
  766. transport.driver_id = driver_id
  767. transport.driver_name = driver_name
  768. transport.driver_phone = driver_phone
  769. transport.escort_id = escort_id
  770. transport.escort_name = escort_name
  771. transport.escort_phone = escort_phone
  772. transport.carrier_id = carrier_id
  773. transport.carrier_name = carrier_name
  774. transport.start_point = plan.pickup_address
  775. transport.end_point = plan.destination_address or plan.destination_name
  776. transport.distance_km = transport_payload.distance_km
  777. transport.transport_qty = transport_qty
  778. transport.freight_price = transport_payload.freight_price
  779. transport.freight_amount = freight_amount or 0
  780. transport.freight_type = transport_payload.freight_type
  781. if transport_payload.has_invoice is not None:
  782. transport.has_invoice = transport_payload.has_invoice
  783. transport.unit = "吨" if transport_payload.freight_type == "by_weight" else "趟"
  784. if transport_payload.remark is not None:
  785. transport.remark = transport_payload.remark
  786. transport.update_by = user_id
  787. def upsert_draft_transport_for_plan(
  788. db: Session,
  789. *,
  790. tenant_no: str,
  791. plan: PlPlan,
  792. transport_payload: PlanTransportCreateBlock,
  793. products: list[PlPlanProduct],
  794. user_id: str,
  795. dept_id: str,
  796. ) -> None:
  797. """写入/更新草稿运输单(pl_transport + pl_plan_transport),计划保持待安排。"""
  798. (
  799. vehicle,
  800. driver_id,
  801. driver_name,
  802. driver_phone,
  803. escort_id,
  804. escort_name,
  805. escort_phone,
  806. carrier_id,
  807. carrier_name,
  808. freight_amount,
  809. transport_qty,
  810. ) = _resolve_transport_context(
  811. db,
  812. tenant_no=tenant_no,
  813. plan=plan,
  814. transport_payload=transport_payload,
  815. products=products,
  816. )
  817. transport: Optional[PlTransport] = None
  818. if plan.transport_id:
  819. transport = (
  820. db.query(PlTransport)
  821. .filter(
  822. PlTransport.id == plan.transport_id,
  823. PlTransport.tenant_no == tenant_no,
  824. PlTransport.del_flag == 0,
  825. )
  826. .first()
  827. )
  828. if transport:
  829. _apply_payload_to_transport_row(
  830. transport,
  831. vehicle=vehicle,
  832. driver_id=driver_id,
  833. driver_name=driver_name,
  834. driver_phone=driver_phone,
  835. escort_id=escort_id,
  836. escort_name=escort_name,
  837. escort_phone=escort_phone,
  838. carrier_id=carrier_id,
  839. carrier_name=carrier_name,
  840. plan=plan,
  841. transport_payload=transport_payload,
  842. freight_amount=freight_amount,
  843. transport_qty=transport_qty,
  844. user_id=user_id,
  845. )
  846. if transport.transport_status == "draft":
  847. pass
  848. else:
  849. transport_no = _generate_transport_no(db)
  850. transport = PlTransport(
  851. id=generate_uuid(),
  852. tenant_no=tenant_no,
  853. transport_no=transport_no,
  854. transport_status="draft",
  855. approval_status="none",
  856. remark=transport_payload.remark,
  857. create_by=user_id,
  858. create_dept_id=dept_id,
  859. )
  860. db.add(transport)
  861. db.flush()
  862. db.add(
  863. PlPlanTransport(
  864. id=generate_uuid(),
  865. tenant_no=tenant_no,
  866. plan_id=plan.id,
  867. transport_id=transport.id,
  868. create_by=user_id,
  869. create_dept_id=dept_id,
  870. )
  871. )
  872. plan.transport_id = transport.id
  873. plan.transport_no = transport_no
  874. _apply_payload_to_transport_row(
  875. transport,
  876. vehicle=vehicle,
  877. driver_id=driver_id,
  878. driver_name=driver_name,
  879. driver_phone=driver_phone,
  880. escort_id=escort_id,
  881. escort_name=escort_name,
  882. escort_phone=escort_phone,
  883. carrier_id=carrier_id,
  884. carrier_name=carrier_name,
  885. plan=plan,
  886. transport_payload=transport_payload,
  887. freight_amount=freight_amount,
  888. transport_qty=transport_qty,
  889. user_id=user_id,
  890. )
  891. plan.vehicle_id = vehicle.id
  892. plan.license_plate = vehicle.license_plate
  893. plan.driver_id = driver_id
  894. plan.driver_name = driver_name
  895. plan.escort_id = escort_id
  896. plan.escort_name = escort_name
  897. plan.escort_phone = escort_phone
  898. _apply_vehicle_snapshot_to_plan_transport(
  899. db,
  900. tenant_no=tenant_no,
  901. plan=plan,
  902. transport=transport,
  903. vehicle=vehicle,
  904. driver_id=driver_id,
  905. escort_id=escort_id,
  906. )
  907. plan.freight_amount = freight_amount or 0
  908. if plan.transport_id:
  909. sync_draft_settlement_freight_for_transport(
  910. db,
  911. tenant_no=tenant_no,
  912. transport_id=plan.transport_id,
  913. freight_amount=freight_amount or Decimal("0"),
  914. user_id=user_id,
  915. )
  916. if plan.transport_status != "arranged":
  917. plan.transport_status = "pending"
  918. def clear_draft_transport_for_plan(
  919. db: Session,
  920. *,
  921. tenant_no: str,
  922. plan: PlPlan,
  923. user_id: str,
  924. ) -> None:
  925. """清除计划上的草稿运输单及车辆预选。"""
  926. transport: Optional[PlTransport] = None
  927. if plan.transport_id:
  928. transport = (
  929. db.query(PlTransport)
  930. .filter(
  931. PlTransport.id == plan.transport_id,
  932. PlTransport.tenant_no == tenant_no,
  933. PlTransport.del_flag == 0,
  934. )
  935. .first()
  936. )
  937. if transport and transport.transport_status == "draft":
  938. transport.del_flag = 1
  939. transport.update_by = user_id
  940. db.query(PlPlanTransport).filter(
  941. PlPlanTransport.plan_id == plan.id,
  942. PlPlanTransport.transport_id == transport.id,
  943. PlPlanTransport.tenant_no == tenant_no,
  944. PlPlanTransport.del_flag == 0,
  945. ).update({"del_flag": 1, "update_by": user_id})
  946. plan.transport_id = None
  947. plan.transport_no = None
  948. plan.vehicle_id = None
  949. plan.license_plate = None
  950. plan.driver_id = None
  951. plan.driver_name = None
  952. plan.escort_id = None
  953. plan.escort_name = None
  954. plan.escort_phone = None
  955. clear_resource_snapshot(plan=plan, transport=transport)
  956. plan.freight_amount = 0
  957. if plan.transport_status != "arranged":
  958. plan.transport_status = "pending"
  959. def sync_transport_prefill_for_plan(
  960. db: Session,
  961. *,
  962. tenant_no: str,
  963. plan: PlPlan,
  964. transport_payload: PlanTransportCreateBlock,
  965. products: list[PlPlanProduct],
  966. user_id: str,
  967. dept_id: str = "",
  968. ) -> None:
  969. """同步运输预选到草稿运输子表(pl_transport)。"""
  970. upsert_draft_transport_for_plan(
  971. db,
  972. tenant_no=tenant_no,
  973. plan=plan,
  974. transport_payload=transport_payload,
  975. products=products,
  976. user_id=user_id,
  977. dept_id=dept_id or user_id,
  978. )
  979. def apply_vehicle_prefill_to_plan(
  980. db: Session,
  981. *,
  982. tenant_no: str,
  983. plan: PlPlan,
  984. transport_payload: PlanTransportCreateBlock,
  985. products: list[PlPlanProduct],
  986. user_id: str = "",
  987. dept_id: str = "",
  988. ) -> None:
  989. """兼容旧调用:写入草稿运输子表。"""
  990. upsert_draft_transport_for_plan(
  991. db,
  992. tenant_no=tenant_no,
  993. plan=plan,
  994. transport_payload=transport_payload,
  995. products=products,
  996. user_id=user_id,
  997. dept_id=dept_id or user_id,
  998. )
  999. def create_transport_for_plan(
  1000. db: Session,
  1001. *,
  1002. tenant_no: str,
  1003. plan: PlPlan,
  1004. products: list[PlPlanProduct],
  1005. transport_payload: PlanTransportCreateBlock,
  1006. user_id: str,
  1007. dept_id: str,
  1008. generate_transport_no,
  1009. ) -> PlTransport:
  1010. vehicle = (
  1011. db.query(PlVehicle)
  1012. .filter(
  1013. PlVehicle.id == transport_payload.vehicle_id,
  1014. PlVehicle.tenant_no == tenant_no,
  1015. PlVehicle.del_flag == 0,
  1016. )
  1017. .first()
  1018. )
  1019. if not vehicle:
  1020. raise HTTPException(status_code=400, detail="车辆不存在")
  1021. validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
  1022. driver_id, driver_name, driver_phone = _resolve_driver(
  1023. db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
  1024. )
  1025. escort_id, escort_name, escort_phone = _resolve_escort(
  1026. db, tenant_no, transport_payload.vehicle_id, transport_payload.escort_id
  1027. )
  1028. carrier_name = vehicle.carrier_name
  1029. carrier_id = transport_payload.carrier_id or vehicle.carrier_id
  1030. if carrier_id:
  1031. carrier = (
  1032. db.query(PlCarrier)
  1033. .filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
  1034. .first()
  1035. )
  1036. if carrier:
  1037. carrier_name = carrier.carrier_name
  1038. freight_amount = _calc_freight_amount(
  1039. transport_payload.freight_type,
  1040. products,
  1041. transport_payload.transport_qty,
  1042. transport_payload.freight_price,
  1043. transport_payload.freight_amount,
  1044. )
  1045. transport_qty = transport_payload.transport_qty
  1046. if transport_qty is None and transport_payload.freight_type == "by_weight":
  1047. transport_qty = sum_plan_product_actual_quantities(products)
  1048. transport_no = generate_transport_no(db)
  1049. transport = PlTransport(
  1050. id=generate_uuid(),
  1051. tenant_no=tenant_no,
  1052. transport_no=transport_no,
  1053. vehicle_id=vehicle.id,
  1054. license_plate=vehicle.license_plate,
  1055. driver_id=driver_id,
  1056. driver_name=driver_name,
  1057. driver_phone=driver_phone,
  1058. escort_id=escort_id,
  1059. escort_name=escort_name,
  1060. escort_phone=escort_phone,
  1061. carrier_id=carrier_id,
  1062. carrier_name=carrier_name,
  1063. start_point=plan.pickup_address,
  1064. end_point=plan.destination_address or plan.destination_name,
  1065. distance_km=transport_payload.distance_km,
  1066. unit="吨" if transport_payload.freight_type == "by_weight" else "趟",
  1067. transport_qty=transport_qty,
  1068. freight_price=transport_payload.freight_price,
  1069. freight_amount=freight_amount or 0,
  1070. freight_type=transport_payload.freight_type,
  1071. has_invoice=transport_payload.has_invoice if transport_payload.has_invoice is not None else 0,
  1072. transport_status="draft",
  1073. approval_status="none",
  1074. remark=transport_payload.remark,
  1075. create_by=user_id,
  1076. create_dept_id=dept_id,
  1077. )
  1078. db.add(transport)
  1079. db.add(
  1080. PlPlanTransport(
  1081. id=generate_uuid(),
  1082. tenant_no=tenant_no,
  1083. plan_id=plan.id,
  1084. transport_id=transport.id,
  1085. create_by=user_id,
  1086. create_dept_id=dept_id,
  1087. )
  1088. )
  1089. plan.transport_id = transport.id
  1090. plan.transport_no = transport_no
  1091. plan.vehicle_id = vehicle.id
  1092. plan.license_plate = vehicle.license_plate
  1093. plan.driver_id = driver_id
  1094. plan.driver_name = driver_name
  1095. plan.escort_id = escort_id
  1096. plan.escort_name = escort_name
  1097. plan.escort_phone = escort_phone
  1098. _apply_vehicle_snapshot_to_plan_transport(
  1099. db,
  1100. tenant_no=tenant_no,
  1101. plan=plan,
  1102. transport=transport,
  1103. vehicle=vehicle,
  1104. driver_id=driver_id,
  1105. escort_id=escort_id,
  1106. )
  1107. plan.transport_status = "arranged"
  1108. plan.follow_status = "arranged"
  1109. plan.freight_amount = freight_amount or 0
  1110. return transport
  1111. PLAN_SELECTABLE_CONTRACT_STATUSES = ("approved", "executing")
  1112. PLAN_CONTRACT_SIDE_SUPPLIER = "supplier"
  1113. PLAN_CONTRACT_SIDE_DEMANDER = "demander"
  1114. PLAN_BELONGS_COMPANY_TRADING_ENTITY = {
  1115. "shanxia": "安庆市山峡化工有限公司",
  1116. "yundong": "安徽云栋化工有限公司",
  1117. }
  1118. def belongs_company_trading_entity(belongs_company: Optional[str]) -> str:
  1119. return PLAN_BELONGS_COMPANY_TRADING_ENTITY.get((belongs_company or "").strip(), "")
  1120. def is_belongs_company_contract_error(exc: HTTPException) -> bool:
  1121. detail = exc.detail if isinstance(exc.detail, str) else ""
  1122. return detail == "请先选择归属公司" or detail.endswith("的交易主体与归属公司不一致")
  1123. def assert_contract_selectable_for_plan(
  1124. db: Session,
  1125. contract_id: str,
  1126. tenant_no: str,
  1127. *,
  1128. expected_customer_id: Optional[str] = None,
  1129. expected_supplier_name: Optional[str] = None,
  1130. side_label: str = "合同",
  1131. ):
  1132. from app.crud.crm.supplier_info import get_supplier_info
  1133. from app.models.crm_models import CrmContract
  1134. contract = (
  1135. db.query(CrmContract)
  1136. .filter(
  1137. CrmContract.id == contract_id,
  1138. CrmContract.tenant_no == tenant_no,
  1139. CrmContract.del_flag == 0,
  1140. )
  1141. .first()
  1142. )
  1143. if not contract:
  1144. raise HTTPException(status_code=404, detail=f"{side_label}不存在")
  1145. if contract.status not in PLAN_SELECTABLE_CONTRACT_STATUSES:
  1146. raise HTTPException(status_code=422, detail=f"仅已审批或执行中的合同可关联运输计划({side_label})")
  1147. if expected_customer_id and contract.customer_id != expected_customer_id:
  1148. raise HTTPException(status_code=422, detail=f"{side_label}不属于所选客户")
  1149. expected_name = (expected_supplier_name or "").strip()
  1150. if expected_name:
  1151. supplier = (
  1152. get_supplier_info(db, contract.supplier_id, tenant_no=tenant_no)
  1153. if contract.supplier_id
  1154. else None
  1155. )
  1156. actual = (supplier.supplier_name if supplier else "") or ""
  1157. if actual.strip() != expected_name:
  1158. raise HTTPException(status_code=422, detail=f"{side_label}的交易主体与归属公司不一致")
  1159. return contract
  1160. def _normalize_contract_links(
  1161. links: Optional[list[PlanContractLink]],
  1162. ) -> list[PlanContractLink]:
  1163. if not links:
  1164. return []
  1165. seen: set[str] = set()
  1166. out: list[PlanContractLink] = []
  1167. for link in links:
  1168. cid = (link.contract_id or "").strip()
  1169. if not cid or cid in seen:
  1170. continue
  1171. seen.add(cid)
  1172. out.append(link)
  1173. return out
  1174. def validate_plan_party_contracts(
  1175. db: Session,
  1176. tenant_no: str,
  1177. *,
  1178. supplier_id: Optional[str],
  1179. supplier_name: Optional[str],
  1180. customer_id: Optional[str],
  1181. customer_name: Optional[str],
  1182. supplier_contracts: Optional[list[PlanContractLink]],
  1183. demander_contracts: Optional[list[PlanContractLink]],
  1184. require_products: Optional[list] = None,
  1185. ) -> tuple[list[PlanContractLink], list[PlanContractLink]]:
  1186. if not (supplier_id or "").strip() or not (supplier_name or "").strip():
  1187. raise HTTPException(status_code=422, detail="请选择供方客户")
  1188. if not (customer_id or "").strip() or not (customer_name or "").strip():
  1189. raise HTTPException(status_code=422, detail="请选择需方客户")
  1190. if require_products is not None and not require_products:
  1191. raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
  1192. supplier_links = _normalize_contract_links(supplier_contracts)
  1193. demander_links = _normalize_contract_links(demander_contracts)
  1194. # Local import: pl_plan_stop_service imports this module at top level.
  1195. from app.services.pl_plan_stop_service import assert_at_most_one_contract
  1196. supplier_links = assert_at_most_one_contract(supplier_links, "供方合同")
  1197. demander_links = assert_at_most_one_contract(demander_links, "需方合同")
  1198. resolved_supplier: list[PlanContractLink] = []
  1199. for link in supplier_links:
  1200. contract = assert_contract_selectable_for_plan(
  1201. db,
  1202. link.contract_id,
  1203. tenant_no,
  1204. expected_customer_id=supplier_id,
  1205. side_label="供方合同",
  1206. )
  1207. resolved_supplier.append(
  1208. PlanContractLink(contractId=contract.id, contractNo=contract.contract_no or link.contract_no)
  1209. )
  1210. resolved_demander: list[PlanContractLink] = []
  1211. for link in demander_links:
  1212. contract = assert_contract_selectable_for_plan(
  1213. db,
  1214. link.contract_id,
  1215. tenant_no,
  1216. expected_customer_id=customer_id,
  1217. side_label="需方合同",
  1218. )
  1219. resolved_demander.append(
  1220. PlanContractLink(contractId=contract.id, contractNo=contract.contract_no or link.contract_no)
  1221. )
  1222. return resolved_supplier, resolved_demander
  1223. def prepare_plan_contract_scope(
  1224. db, *, tenant_no, plan_id, side, stop_id=None, contracts=None,
  1225. ):
  1226. from app.services.pl_child_sync import prepare_keyed_rows
  1227. if contracts is None:
  1228. return None
  1229. scope = db.query(PlPlanContract).filter(
  1230. PlPlanContract.tenant_no == tenant_no, PlPlanContract.plan_id == plan_id,
  1231. PlPlanContract.side == side, PlPlanContract.del_flag == 0)
  1232. if stop_id:
  1233. scope = scope.filter(PlPlanContract.stop_id == stop_id)
  1234. else:
  1235. scope = scope.filter(or_(PlPlanContract.stop_id.is_(None), PlPlanContract.stop_id == ''))
  1236. values = [dict(tenant_no=tenant_no, plan_id=plan_id, side=side, stop_id=stop_id,
  1237. contract_id=link.contract_id, contract_no=link.contract_no) for link in contracts]
  1238. return prepare_keyed_rows(scope.all(), values, ('contract_id',), '计划合同关联')
  1239. def sync_plan_contract_scope(
  1240. db, *, tenant_no, plan_id, side, stop_id=None, contracts=None,
  1241. user_id=None, dept_id=None, order_start=0,
  1242. ):
  1243. from app.services.pl_child_sync import apply_keyed_rows
  1244. prepared = prepare_plan_contract_scope(db, tenant_no=tenant_no, plan_id=plan_id,
  1245. side=side, stop_id=stop_id, contracts=contracts)
  1246. if prepared is not None:
  1247. apply_keyed_rows(db, PlPlanContract, prepared, user_id=user_id,
  1248. dept_id=dept_id, order_start=order_start)
  1249. def replace_plan_contracts(
  1250. db: Session, *, tenant_no: str, plan_id: str,
  1251. supplier_contracts: Optional[list[PlanContractLink]],
  1252. demander_contracts: Optional[list[PlanContractLink]],
  1253. user_id: Optional[str], dept_id: Optional[str],
  1254. ) -> None:
  1255. from app.services.pl_child_sync import apply_keyed_rows
  1256. prepared = [prepare_plan_contract_scope(db, tenant_no=tenant_no, plan_id=plan_id,
  1257. side=side, contracts=links) for side, links in
  1258. ((PLAN_CONTRACT_SIDE_SUPPLIER, supplier_contracts),
  1259. (PLAN_CONTRACT_SIDE_DEMANDER, demander_contracts))]
  1260. for scope in prepared:
  1261. if scope is not None:
  1262. apply_keyed_rows(db, PlPlanContract, scope, user_id=user_id, dept_id=dept_id)
  1263. def load_plan_contracts_grouped(
  1264. db: Session,
  1265. tenant_no: str,
  1266. plan_ids: list[str],
  1267. ) -> dict[str, tuple[list[PlanContractLink], list[PlanContractLink]]]:
  1268. if not plan_ids:
  1269. return {}
  1270. rows = (
  1271. db.query(PlPlanContract)
  1272. .filter(
  1273. PlPlanContract.tenant_no == tenant_no,
  1274. PlPlanContract.plan_id.in_(plan_ids),
  1275. PlPlanContract.del_flag == 0,
  1276. )
  1277. .order_by(PlPlanContract.order_num.asc(), PlPlanContract.create_time.asc())
  1278. .all()
  1279. )
  1280. buckets: dict[str, list[list[PlanContractLink]]] = {
  1281. pid: [[], []] for pid in plan_ids
  1282. }
  1283. for row in rows:
  1284. bucket = buckets.setdefault(row.plan_id, [[], []])
  1285. link = PlanContractLink(
  1286. contractId=row.contract_id,
  1287. contractNo=row.contract_no,
  1288. stopId=row.stop_id,
  1289. )
  1290. if row.side == PLAN_CONTRACT_SIDE_SUPPLIER:
  1291. bucket[0].append(link)
  1292. elif row.side == PLAN_CONTRACT_SIDE_DEMANDER:
  1293. bucket[1].append(link)
  1294. return {pid: (lists[0], lists[1]) for pid, lists in buckets.items()}
  1295. DRAFTABLE_FOLLOW_STATUSES = frozenset({"drafting", "pending_arrange"})
  1296. def validate_draft_min_fields(*, belongs_company: Optional[str], create_date) -> None:
  1297. if not (belongs_company or "").strip():
  1298. raise HTTPException(status_code=422, detail="请选择归属公司")
  1299. # create_date 允许为空,由 create/update 用 _resolve_create_date 兜底当天
  1300. def resolve_follow_status_on_save(
  1301. *,
  1302. save_mode: str,
  1303. current: Optional[str],
  1304. is_create: bool,
  1305. ) -> str:
  1306. mode = (save_mode or "formal").strip() or "formal"
  1307. if mode == "draft":
  1308. return "drafting"
  1309. if is_create:
  1310. return "pending_arrange"
  1311. if current == "drafting":
  1312. return "pending_arrange"
  1313. return (current or "pending_arrange").strip() or "pending_arrange"
  1314. def assert_can_save_as_draft(current_status: Optional[str]) -> None:
  1315. status = (current_status or "").strip()
  1316. if status not in DRAFTABLE_FOLLOW_STATUSES:
  1317. raise HTTPException(status_code=400, detail="当前状态不可暂存")
  1318. def validate_create_full(payload: PlanCreateFull) -> None:
  1319. if not (payload.supplier_id or "").strip() or not (payload.supplier_name or "").strip():
  1320. raise HTTPException(status_code=422, detail="请选择供方客户")
  1321. if not (payload.customer_id or "").strip() or not (payload.customer_name or "").strip():
  1322. raise HTTPException(status_code=422, detail="请选择需方客户")
  1323. if not payload.products:
  1324. raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
  1325. transport = payload.transport
  1326. if not transport or not transport.vehicle_id:
  1327. return
  1328. if payload.has_freight:
  1329. if transport.freight_type == "by_weight":
  1330. if not transport.freight_price or Decimal(str(transport.freight_price)) <= 0:
  1331. raise HTTPException(status_code=422, detail="按重量计费时请填写运输单价")
  1332. qty = transport.transport_qty or sum_product_actual_quantities(payload.products)
  1333. if qty <= 0:
  1334. raise HTTPException(status_code=422, detail="运输量必须大于0,请填写产品实际数量或运输量")
  1335. elif transport.freight_type == "by_trip":
  1336. if not transport.freight_amount or Decimal(str(transport.freight_amount)) <= 0:
  1337. raise HTTPException(status_code=422, detail="按趟计费时请填写运费金额")
  1338. def calc_plan_total_amount(products: list[PlanProductCreate]) -> Decimal:
  1339. total = Decimal("0")
  1340. for prod in products:
  1341. if prod.tax_subtotal is not None:
  1342. total += Decimal(str(prod.tax_subtotal))
  1343. return total
  1344. _LOCKED_SETTLEMENT_STATUSES = ("approving", "approved", "confirmed")
  1345. def transport_has_locked_freight_settlement(
  1346. db: Session, tenant_no: str, transport_id: Optional[str]
  1347. ) -> bool:
  1348. if not transport_id:
  1349. return False
  1350. return (
  1351. db.query(PlFreightSettlementTransport.id)
  1352. .join(
  1353. PlFreightSettlement,
  1354. PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
  1355. )
  1356. .filter(
  1357. PlFreightSettlementTransport.transport_id == transport_id,
  1358. PlFreightSettlementTransport.tenant_no == tenant_no,
  1359. PlFreightSettlementTransport.del_flag == 0,
  1360. PlFreightSettlement.tenant_no == tenant_no,
  1361. PlFreightSettlement.del_flag == 0,
  1362. PlFreightSettlement.settlement_status.in_(_LOCKED_SETTLEMENT_STATUSES),
  1363. )
  1364. .first()
  1365. is not None
  1366. )
  1367. def sync_archive_qty_from_io_order(
  1368. db: Session,
  1369. *,
  1370. tenant_no: str,
  1371. plan: PlPlan,
  1372. items: list[PlanArchiveProductItem],
  1373. ) -> list[PlanArchiveProductItem]:
  1374. """若存在入库/出库单,用明细 actual_qty 覆盖归档提交中的实际数量(权威来源)。"""
  1375. from app.models.pl_models import PlInboundOrder, PlInboundOrderItem, PlOutboundOrder, PlOutboundOrderItem
  1376. if plan.plan_type not in ("inbound", "outbound"):
  1377. return items
  1378. header_m = PlInboundOrder if plan.plan_type == "inbound" else PlOutboundOrder
  1379. item_m = PlInboundOrderItem if plan.plan_type == "inbound" else PlOutboundOrderItem
  1380. header = (
  1381. db.query(header_m)
  1382. .filter(
  1383. header_m.tenant_no == tenant_no,
  1384. header_m.plan_id == plan.id,
  1385. header_m.status != "voided",
  1386. header_m.del_flag == 0,
  1387. )
  1388. .order_by(header_m.create_time.desc())
  1389. .first()
  1390. )
  1391. if not header:
  1392. return items
  1393. io_items = (
  1394. db.query(item_m)
  1395. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  1396. .all()
  1397. )
  1398. qty_by_plan_product = {
  1399. row.plan_product_id: Decimal(str(row.actual_qty))
  1400. for row in io_items
  1401. if row.actual_qty is not None
  1402. }
  1403. if not qty_by_plan_product:
  1404. return items
  1405. out: list[PlanArchiveProductItem] = []
  1406. for item in items:
  1407. qty = qty_by_plan_product.get(item.id)
  1408. if qty is not None:
  1409. out.append(
  1410. PlanArchiveProductItem(
  1411. id=item.id,
  1412. actual_qty=qty,
  1413. inbound_qty=item.inbound_qty,
  1414. inbound_price=item.inbound_price,
  1415. outbound_qty=item.outbound_qty,
  1416. outbound_price=item.outbound_price,
  1417. brand_qtys=item.brand_qtys,
  1418. )
  1419. )
  1420. else:
  1421. out.append(item)
  1422. return out
  1423. def apply_archive_product_quantities(
  1424. db: Session,
  1425. *,
  1426. tenant_no: str,
  1427. plan: PlPlan,
  1428. items: list[PlanArchiveProductItem],
  1429. user_id: str,
  1430. sync_from_io_order: bool = True,
  1431. ) -> None:
  1432. if sync_from_io_order:
  1433. items = sync_archive_qty_from_io_order(db, tenant_no=tenant_no, plan=plan, items=items)
  1434. product_map = {
  1435. row.id: row
  1436. for row in db.query(PlPlanProduct).filter(
  1437. PlPlanProduct.plan_id == plan.id,
  1438. PlPlanProduct.tenant_no == tenant_no,
  1439. PlPlanProduct.del_flag == 0,
  1440. ).all()
  1441. }
  1442. for item in items:
  1443. prod = product_map.get(item.id)
  1444. if not prod:
  1445. raise HTTPException(status_code=400, detail=f"产品明细不存在: {item.id}")
  1446. actual = (
  1447. Decimal(str(item.actual_qty)) if item.actual_qty is not None else None
  1448. )
  1449. if actual is not None:
  1450. prod.actual_qty = actual
  1451. if item.inbound_qty is not None:
  1452. prod.inbound_qty = Decimal(str(item.inbound_qty))
  1453. elif actual is not None and plan.plan_type == "inbound":
  1454. prod.inbound_qty = actual
  1455. if item.inbound_price is not None:
  1456. prod.inbound_price = Decimal(str(item.inbound_price))
  1457. inbound_qty = prod.inbound_qty
  1458. inbound_price = prod.inbound_price or prod.tax_price
  1459. if inbound_qty is not None and inbound_price is not None:
  1460. prod.inbound_amount = Decimal(str(inbound_qty)) * Decimal(str(inbound_price))
  1461. if item.outbound_qty is not None:
  1462. prod.outbound_qty = Decimal(str(item.outbound_qty))
  1463. elif actual is not None and plan.plan_type == "outbound":
  1464. prod.outbound_qty = actual
  1465. if item.outbound_price is not None:
  1466. prod.outbound_price = Decimal(str(item.outbound_price))
  1467. outbound_qty = prod.outbound_qty
  1468. outbound_price = prod.outbound_price or prod.tax_price
  1469. if outbound_qty is not None and outbound_price is not None:
  1470. prod.outbound_amount = Decimal(str(outbound_qty)) * Decimal(str(outbound_price))
  1471. prod.update_by = user_id
  1472. brand_rows = None if item.brand_qtys is None else []
  1473. if item.brand_qtys:
  1474. brand_rows = [
  1475. {
  1476. "brandId": bq.brand_id or "",
  1477. "brandName": bq.brand_name,
  1478. "qty": bq.qty,
  1479. **({"unitPrice": bq.unit_price} if bq.unit_price is not None else {}),
  1480. }
  1481. for bq in item.brand_qtys
  1482. ]
  1483. if actual is not None and brand_rows is not None:
  1484. try:
  1485. validate_brand_qty_rows(
  1486. brand_mode=prod.brand_mode,
  1487. required_brand_id=prod.brand_id,
  1488. actual_qty=actual,
  1489. rows=brand_rows,
  1490. )
  1491. except ValueError as exc:
  1492. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1493. persist_plan_product_brand_qtys(
  1494. db,
  1495. tenant_no=tenant_no,
  1496. plan_id=plan.id,
  1497. plan_product_id=prod.id,
  1498. brand_mode=prod.brand_mode,
  1499. rows=brand_rows,
  1500. user_id=user_id,
  1501. dept_id=getattr(prod, "create_dept_id", "") or "",
  1502. )
  1503. def apply_archive_brand_qtys_only(
  1504. db: Session,
  1505. *,
  1506. tenant_no: str,
  1507. plan: PlPlan,
  1508. items: list[PlanArchiveProductItem],
  1509. user_id: str,
  1510. ) -> None:
  1511. """一车多卸:段级数量已写入,此处仅处理计划产品品牌分量。"""
  1512. product_map = {
  1513. row.id: row
  1514. for row in db.query(PlPlanProduct).filter(
  1515. PlPlanProduct.plan_id == plan.id,
  1516. PlPlanProduct.tenant_no == tenant_no,
  1517. PlPlanProduct.del_flag == 0,
  1518. ).all()
  1519. }
  1520. for item in items:
  1521. if item.brand_qtys is None:
  1522. continue
  1523. prod = product_map.get(item.id)
  1524. if not prod:
  1525. raise HTTPException(status_code=400, detail=f"产品明细不存在: {item.id}")
  1526. actual = Decimal(str(prod.actual_qty or 0))
  1527. brand_rows = [
  1528. {
  1529. "brandId": bq.brand_id or "",
  1530. "brandName": bq.brand_name,
  1531. "qty": bq.qty,
  1532. **({"unitPrice": bq.unit_price} if bq.unit_price is not None else {}),
  1533. }
  1534. for bq in item.brand_qtys
  1535. ]
  1536. try:
  1537. validate_brand_qty_rows(
  1538. brand_mode=prod.brand_mode,
  1539. required_brand_id=prod.brand_id,
  1540. actual_qty=actual,
  1541. rows=brand_rows,
  1542. )
  1543. except ValueError as exc:
  1544. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1545. persist_plan_product_brand_qtys(
  1546. db,
  1547. tenant_no=tenant_no,
  1548. plan_id=plan.id,
  1549. plan_product_id=prod.id,
  1550. brand_mode=prod.brand_mode,
  1551. rows=brand_rows,
  1552. user_id=user_id,
  1553. dept_id=getattr(prod, "create_dept_id", "") or "",
  1554. )
  1555. def apply_archive_freight(
  1556. db: Session,
  1557. *,
  1558. tenant_no: str,
  1559. plan: PlPlan,
  1560. freight: PlanArchiveFreightBlock,
  1561. user_id: str,
  1562. strict: bool = True,
  1563. ) -> None:
  1564. has_freight = int(freight.has_freight if freight.has_freight is not None else plan.has_freight or 0)
  1565. plan.has_freight = has_freight
  1566. if freight.freight_remark is not None:
  1567. plan.freight_remark = freight.freight_remark
  1568. plan.update_by = user_id
  1569. if has_freight != 1:
  1570. plan.freight_amount = Decimal("0")
  1571. if not plan.transport_id:
  1572. return
  1573. if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
  1574. raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
  1575. transport = (
  1576. db.query(PlTransport)
  1577. .filter(
  1578. PlTransport.id == plan.transport_id,
  1579. PlTransport.tenant_no == tenant_no,
  1580. PlTransport.del_flag == 0,
  1581. )
  1582. .first()
  1583. )
  1584. if not transport:
  1585. raise HTTPException(status_code=400, detail="关联运输单不存在")
  1586. transport.freight_amount = Decimal("0")
  1587. transport.freight_price = None
  1588. transport.transport_qty = None
  1589. transport.update_by = user_id
  1590. sync_draft_settlement_freight_for_transport(
  1591. db,
  1592. tenant_no=tenant_no,
  1593. transport_id=plan.transport_id,
  1594. freight_amount=Decimal("0"),
  1595. user_id=user_id,
  1596. )
  1597. return
  1598. if not plan.transport_id:
  1599. if strict:
  1600. raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
  1601. return
  1602. if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
  1603. raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
  1604. transport = (
  1605. db.query(PlTransport)
  1606. .filter(
  1607. PlTransport.id == plan.transport_id,
  1608. PlTransport.tenant_no == tenant_no,
  1609. PlTransport.del_flag == 0,
  1610. )
  1611. .first()
  1612. )
  1613. if not transport:
  1614. raise HTTPException(status_code=400, detail="关联运输单不存在")
  1615. transport.distance_km = freight.distance_km
  1616. transport.freight_type = freight.freight_type or "by_weight"
  1617. transport.freight_price = freight.freight_price
  1618. amount = freight.freight_amount or Decimal("0")
  1619. transport.freight_amount = amount
  1620. freight_type = freight.freight_type or transport.freight_type or "by_weight"
  1621. plan_products = (
  1622. db.query(PlPlanProduct)
  1623. .filter(
  1624. PlPlanProduct.plan_id == plan.id,
  1625. PlPlanProduct.tenant_no == tenant_no,
  1626. PlPlanProduct.del_flag == 0,
  1627. )
  1628. .all()
  1629. )
  1630. if freight.transport_qty is not None:
  1631. transport.transport_qty = freight.transport_qty
  1632. elif freight_type == "by_weight":
  1633. from app.services.pl_plan_archive_stop_service import sum_delivery_outbound_qty
  1634. from app.models.pl_models import PlPlanLoad
  1635. has_load_stop = (
  1636. db.query(PlPlanLoad.id)
  1637. .filter(
  1638. PlPlanLoad.plan_id == plan.id,
  1639. PlPlanLoad.tenant_no == tenant_no,
  1640. PlPlanLoad.del_flag == 0,
  1641. )
  1642. .first()
  1643. )
  1644. if has_load_stop:
  1645. transport.transport_qty = sum_delivery_outbound_qty(
  1646. db, tenant_no=tenant_no, plan_id=plan.id
  1647. )
  1648. else:
  1649. transport.transport_qty = sum_plan_product_actual_quantities(plan_products)
  1650. if freight_type == "by_weight" and (transport.transport_qty or 0) <= 0:
  1651. if strict:
  1652. raise HTTPException(status_code=400, detail="运输量必须大于0,请先填写产品实际数量")
  1653. # 草稿:允许运输量暂缺
  1654. if freight.has_invoice is not None:
  1655. transport.has_invoice = freight.has_invoice
  1656. transport.update_by = user_id
  1657. plan.freight_amount = amount
  1658. sync_draft_settlement_freight_for_transport(
  1659. db,
  1660. tenant_no=tenant_no,
  1661. transport_id=plan.transport_id,
  1662. freight_amount=amount,
  1663. user_id=user_id,
  1664. )
  1665. def record_plan_change(
  1666. db: Session,
  1667. *,
  1668. tenant_no: str,
  1669. plan_id: str,
  1670. change_type: str,
  1671. change_content: str,
  1672. user_id: str,
  1673. user_name: Optional[str],
  1674. dept_id: str,
  1675. ) -> None:
  1676. db.add(
  1677. PlPlanChange(
  1678. id=generate_uuid(),
  1679. tenant_no=tenant_no,
  1680. plan_id=plan_id,
  1681. change_type=change_type,
  1682. change_content=change_content,
  1683. change_user_id=user_id,
  1684. change_user_name=user_name,
  1685. create_by=user_id,
  1686. create_dept_id=dept_id,
  1687. )
  1688. )
  1689. def apply_plan_driver_feedback(
  1690. db: Session,
  1691. *,
  1692. tenant_no: str,
  1693. plan: PlPlan,
  1694. payload: PlanDriverFeedbackUpdateBody,
  1695. user_id: str,
  1696. ) -> PlTransport:
  1697. if plan.follow_status == "cancelled":
  1698. raise HTTPException(status_code=400, detail="已作废的计划不能编辑司机回传")
  1699. if not plan.transport_id:
  1700. raise HTTPException(status_code=400, detail="计划未安排运输,无法编辑司机回传")
  1701. transport = (
  1702. db.query(PlTransport)
  1703. .filter(
  1704. PlTransport.id == plan.transport_id,
  1705. PlTransport.tenant_no == tenant_no,
  1706. PlTransport.del_flag == 0,
  1707. )
  1708. .first()
  1709. )
  1710. if not transport:
  1711. raise HTTPException(status_code=404, detail="运输单不存在")
  1712. if payload.products is not None:
  1713. products_payload = [
  1714. {
  1715. "planProductId": item.plan_product_id,
  1716. "actualQty": item.actual_qty,
  1717. "brandQtys": ([b.model_dump(by_alias=True) for b in item.brand_qtys] if item.brand_qtys is not None else None),
  1718. }
  1719. for item in payload.products
  1720. ]
  1721. try:
  1722. persist_transport_product_feedback(
  1723. db,
  1724. tenant_no=tenant_no,
  1725. transport=transport,
  1726. plan_id=plan.id,
  1727. products=products_payload,
  1728. user_id=user_id,
  1729. enforce_brand=True,
  1730. )
  1731. except ValueError as exc:
  1732. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1733. elif payload.feedback_qty is not None:
  1734. transport.feedback_qty = payload.feedback_qty
  1735. if payload.feedback_remark is not None:
  1736. transport.feedback_remark = payload.feedback_remark.strip() if payload.feedback_remark else None
  1737. if payload.feedback_images is not None:
  1738. transport.feedback_images = json.dumps([item for item in payload.feedback_images if item])
  1739. transport.feedback_time = payload.feedback_time or datetime.now()
  1740. transport.update_by = user_id
  1741. return transport
  1742. def apply_plan_site_feedback(
  1743. db: Session,
  1744. *,
  1745. tenant_no: str,
  1746. plan: PlPlan,
  1747. link_id: str,
  1748. payload: PlanSiteFeedbackUpdateBody,
  1749. user_id: str,
  1750. ) -> PlTransportSiteLink:
  1751. if plan.follow_status == "cancelled":
  1752. raise HTTPException(status_code=400, detail="已作废的计划不能编辑现场回传")
  1753. link = (
  1754. db.query(PlTransportSiteLink)
  1755. .filter(
  1756. PlTransportSiteLink.id == link_id,
  1757. PlTransportSiteLink.plan_id == plan.id,
  1758. PlTransportSiteLink.tenant_no == tenant_no,
  1759. PlTransportSiteLink.del_flag == 0,
  1760. )
  1761. .first()
  1762. )
  1763. if not link:
  1764. raise HTTPException(status_code=404, detail="现场链接不存在")
  1765. if payload.site_feedback_remark is not None:
  1766. link.site_feedback_remark = (
  1767. payload.site_feedback_remark.strip() if payload.site_feedback_remark else None
  1768. )
  1769. if payload.site_feedback_images is not None:
  1770. link.site_feedback_images = json.dumps([item for item in payload.site_feedback_images if item])
  1771. link.site_feedback_time = payload.site_feedback_time or datetime.now()
  1772. link.update_by = user_id
  1773. return link
  1774. def _resolve_stop_business_type_for_archive(
  1775. db: Session,
  1776. *,
  1777. tenant_no: str,
  1778. plan_id: str,
  1779. stop_type: str,
  1780. stop_id: str,
  1781. ) -> str:
  1782. from app.models.pl_models import PlPlanDelivery, PlPlanLoad
  1783. direction: Optional[str] = None
  1784. if stop_type == "load":
  1785. row = (
  1786. db.query(PlPlanLoad)
  1787. .filter(
  1788. PlPlanLoad.id == stop_id,
  1789. PlPlanLoad.plan_id == plan_id,
  1790. PlPlanLoad.tenant_no == tenant_no,
  1791. PlPlanLoad.del_flag == 0,
  1792. )
  1793. .first()
  1794. )
  1795. direction = (row.dibang_direction or "").strip() if row else None
  1796. else:
  1797. row = (
  1798. db.query(PlPlanDelivery)
  1799. .filter(
  1800. PlPlanDelivery.id == stop_id,
  1801. PlPlanDelivery.plan_id == plan_id,
  1802. PlPlanDelivery.tenant_no == tenant_no,
  1803. PlPlanDelivery.del_flag == 0,
  1804. )
  1805. .first()
  1806. )
  1807. direction = (row.dibang_direction or "").strip() if row else None
  1808. if direction == "outbound":
  1809. return "出库"
  1810. if direction == "inbound":
  1811. return "入库"
  1812. return "出库"
  1813. def apply_archive_stop_feedback_attachments(
  1814. db: Session,
  1815. *,
  1816. tenant_no: str,
  1817. plan: PlPlan,
  1818. feedbacks: PlanArchiveFeedbacksBlock,
  1819. user_id: str,
  1820. dept_id: str = "",
  1821. ) -> None:
  1822. if not plan.transport_id:
  1823. return
  1824. from app.models.pl_models import PlTransportDriverStopFeedback, PlTransportSiteStopFeedback
  1825. from app.services.pl_transport_stop_feedback_service import (
  1826. save_driver_stop_feedback,
  1827. save_site_stop_feedback,
  1828. )
  1829. transport_id = plan.transport_id
  1830. plan_id = plan.id
  1831. for item in feedbacks.driver_stops or []:
  1832. urls = [url for url in (item.attachment_urls or []) if url]
  1833. existing = (
  1834. db.query(PlTransportDriverStopFeedback)
  1835. .filter(
  1836. PlTransportDriverStopFeedback.transport_id == transport_id,
  1837. PlTransportDriverStopFeedback.tenant_no == tenant_no,
  1838. PlTransportDriverStopFeedback.stop_type == item.stop_type,
  1839. PlTransportDriverStopFeedback.stop_id == item.stop_id,
  1840. PlTransportDriverStopFeedback.del_flag == 0,
  1841. )
  1842. .first()
  1843. )
  1844. if not urls and not existing:
  1845. continue
  1846. save_driver_stop_feedback(
  1847. db,
  1848. tenant_no=tenant_no,
  1849. transport_id=transport_id,
  1850. plan_id=plan_id,
  1851. stop_type=item.stop_type,
  1852. stop_id=item.stop_id,
  1853. attachment_urls=urls,
  1854. user_id=user_id,
  1855. dept_id=dept_id,
  1856. )
  1857. for item in feedbacks.site_stops or []:
  1858. urls = [url for url in (item.attachment_urls or []) if url]
  1859. existing = (
  1860. db.query(PlTransportSiteStopFeedback)
  1861. .filter(
  1862. PlTransportSiteStopFeedback.transport_id == transport_id,
  1863. PlTransportSiteStopFeedback.tenant_no == tenant_no,
  1864. PlTransportSiteStopFeedback.stop_type == item.stop_type,
  1865. PlTransportSiteStopFeedback.stop_id == item.stop_id,
  1866. PlTransportSiteStopFeedback.del_flag == 0,
  1867. )
  1868. .first()
  1869. )
  1870. if not urls and not existing:
  1871. continue
  1872. business_type = _resolve_stop_business_type_for_archive(
  1873. db,
  1874. tenant_no=tenant_no,
  1875. plan_id=plan_id,
  1876. stop_type=item.stop_type,
  1877. stop_id=item.stop_id,
  1878. )
  1879. save_site_stop_feedback(
  1880. db,
  1881. tenant_no=tenant_no,
  1882. transport_id=transport_id,
  1883. plan_id=plan_id,
  1884. stop_type=item.stop_type,
  1885. stop_id=item.stop_id,
  1886. business_type=business_type,
  1887. attachment_urls=urls,
  1888. user_id=user_id,
  1889. dept_id=dept_id,
  1890. require_qty_before_attachments=False,
  1891. )