pl_dibang_bridge_service.py 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136
  1. """运输计划安排时联动地磅计划与出入库单。"""
  2. from __future__ import annotations
  3. import calendar
  4. import re
  5. from dataclasses import dataclass
  6. from datetime import date, datetime
  7. from decimal import Decimal
  8. from typing import Any, Literal, Optional
  9. from sqlalchemy.orm import Session
  10. from app.core.utils import generate_uuid
  11. from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
  12. from app.models.pl_models import (
  13. PlPlan,
  14. PlPlanDelivery,
  15. PlPlanDeliveryProduct,
  16. PlPlanLoad,
  17. PlPlanLoadProduct,
  18. PlPlanProduct,
  19. PlTransport,
  20. PlTransportDbLink,
  21. )
  22. from app.services.db.inventory_id import next_inbound_outbound_id
  23. from app.services.db.mappers import parse_date_str
  24. from app.services.db.plan_helpers import default_execution_status, generate_plan_no
  25. from app.services.db.product_helpers import (
  26. apply_product_snapshot_to_inventory,
  27. apply_product_snapshot_to_plan,
  28. resolve_crm_product,
  29. )
  30. from app.services.pl_plan_stop_rules import normalize_stop_dibang_fields
  31. @dataclass
  32. class DibangInventoryLinkResult:
  33. plan_product_id: str
  34. product_name: str
  35. db_purchase_plan_id: str
  36. db_inventory_id: str
  37. batch_no: str
  38. plan_no: str
  39. @dataclass
  40. class StopDibangOption:
  41. stop_type: Literal["load", "delivery"]
  42. stop_id: str
  43. generate_dibang: bool
  44. @dataclass
  45. class _StopDibangProduct:
  46. plan_product_id: str
  47. product_id: str
  48. product_name: str
  49. model: Optional[str]
  50. unit: str
  51. quantity: float
  52. def resolve_partner_company(plan: PlPlan, plan_type: str) -> str:
  53. """按运输计划单据类型解析地磅往来单位(不区分 CRM 合同类型)。
  54. - 入库单 inbound:供方 → party_a,其次 supplier_name
  55. - 出库单 outbound:需方 → party_b,其次 customer_name
  56. """
  57. if plan_type == "inbound":
  58. return (plan.party_a or plan.supplier_name or "").strip()
  59. if plan_type == "outbound":
  60. return (plan.party_b or plan.customer_name or "").strip()
  61. return ""
  62. def _normalize_to_date(value: date | datetime | str | None) -> Optional[date]:
  63. if value is None:
  64. return None
  65. if isinstance(value, datetime):
  66. return value.date()
  67. if isinstance(value, date):
  68. return value
  69. if isinstance(value, str):
  70. return parse_date_str(value)
  71. return None
  72. def _normalize_plan_date(plan: PlPlan) -> date:
  73. plan_date = plan.plan_date
  74. if isinstance(plan_date, datetime):
  75. return plan_date.date()
  76. if isinstance(plan_date, date):
  77. return plan_date
  78. if isinstance(plan_date, str):
  79. parsed = parse_date_str(plan_date)
  80. return parsed or date.today()
  81. return date.today()
  82. def _resolve_dibang_date(plan: PlPlan) -> date:
  83. """入库单用送达时间,出库单用取货时间;缺失时回退计划日期。"""
  84. if plan.plan_type == "inbound":
  85. resolved = _normalize_to_date(plan.delivery_time)
  86. elif plan.plan_type == "outbound":
  87. resolved = _normalize_to_date(plan.pickup_time)
  88. else:
  89. resolved = None
  90. return resolved or _normalize_plan_date(plan)
  91. def _month_range(plan_date: date) -> tuple[date, date]:
  92. month_start = plan_date.replace(day=1)
  93. last_day = calendar.monthrange(plan_date.year, plan_date.month)[1]
  94. month_end = plan_date.replace(day=last_day)
  95. return month_start, month_end
  96. def _plan_type_to_business_type(plan_type: str) -> Optional[str]:
  97. if plan_type == "inbound":
  98. return "入库"
  99. if plan_type == "outbound":
  100. return "出库"
  101. return None
  102. def _direction_to_business_type(direction: Optional[str]) -> Optional[str]:
  103. normalized = (direction or "").strip()
  104. if normalized == "outbound":
  105. return "出库"
  106. if normalized == "inbound":
  107. return "入库"
  108. return None
  109. def _resolve_stop_business_type(
  110. stop: PlPlanLoad | PlPlanDelivery,
  111. stop_type: Literal["load", "delivery"],
  112. ) -> Optional[str]:
  113. _, direction = normalize_stop_dibang_fields(
  114. stop.customer_name,
  115. stop_type,
  116. stop.generate_dibang,
  117. stop.dibang_direction,
  118. )
  119. return _direction_to_business_type(direction)
  120. def resolve_stop_dibang_partner(
  121. stop_type: Literal["load", "delivery"],
  122. load: Optional[PlPlanLoad],
  123. deliveries: list[PlPlanDelivery],
  124. ) -> str:
  125. """装货段地磅往来单位取第一个送货客户;送货段取装货客户。"""
  126. if stop_type == "load":
  127. if deliveries:
  128. return (deliveries[0].customer_name or "").strip()
  129. return (load.customer_name or "").strip() if load else ""
  130. return (load.customer_name or "").strip() if load else ""
  131. def _stop_product_quantity(product: Any) -> float:
  132. if getattr(product, "plan_qty", None) is not None:
  133. return float(product.plan_qty)
  134. return 0.0
  135. def plan_has_load_stop(db: Session, tenant_no: str, plan_id: str) -> bool:
  136. return (
  137. db.query(PlPlanLoad.id)
  138. .filter(
  139. PlPlanLoad.plan_id == plan_id,
  140. PlPlanLoad.tenant_no == tenant_no,
  141. PlPlanLoad.del_flag == 0,
  142. )
  143. .first()
  144. is not None
  145. )
  146. def apply_stop_dibang_options(
  147. db: Session,
  148. *,
  149. tenant_no: str,
  150. plan_id: str,
  151. stop_options: list[StopDibangOption],
  152. user_id: Optional[str],
  153. ) -> None:
  154. for option in stop_options:
  155. if option.stop_type == "load":
  156. stop = (
  157. db.query(PlPlanLoad)
  158. .filter(
  159. PlPlanLoad.id == option.stop_id,
  160. PlPlanLoad.plan_id == plan_id,
  161. PlPlanLoad.tenant_no == tenant_no,
  162. PlPlanLoad.del_flag == 0,
  163. )
  164. .first()
  165. )
  166. else:
  167. stop = (
  168. db.query(PlPlanDelivery)
  169. .filter(
  170. PlPlanDelivery.id == option.stop_id,
  171. PlPlanDelivery.plan_id == plan_id,
  172. PlPlanDelivery.tenant_no == tenant_no,
  173. PlPlanDelivery.del_flag == 0,
  174. )
  175. .first()
  176. )
  177. if stop:
  178. gen, direction = normalize_stop_dibang_fields(
  179. stop.customer_name,
  180. "load" if option.stop_type == "load" else "delivery",
  181. 1 if option.generate_dibang else 0,
  182. stop.dibang_direction,
  183. )
  184. stop.generate_dibang = gen
  185. stop.dibang_direction = direction
  186. stop.update_by = user_id
  187. db.flush()
  188. def _product_quantity(product: PlPlanProduct) -> float:
  189. for value in (product.quantity, product.inbound_estimate_qty, product.outbound_estimate_qty):
  190. if value is not None:
  191. return float(value)
  192. return 0.0
  193. def _apply_plan_quantity_delta(
  194. db_plan: DbPurchasePlan,
  195. delta: float,
  196. *,
  197. user_id: Optional[str],
  198. ) -> None:
  199. current = float(db_plan.planned_quantity or 0)
  200. db_plan.planned_quantity = max(0.0, current + float(delta))
  201. db_plan.update_by = user_id
  202. def _resolve_link_contributed_qty(
  203. link: PlTransportDbLink,
  204. inventory: Optional[DbInboundOutbound],
  205. ) -> float:
  206. if link.contributed_qty is not None:
  207. return float(link.contributed_qty)
  208. if inventory and inventory.quantity is not None:
  209. return float(inventory.quantity)
  210. return 0.0
  211. def _subtract_plan_contribution(
  212. db: Session,
  213. *,
  214. tenant_no: str,
  215. link: PlTransportDbLink,
  216. inventory: Optional[DbInboundOutbound],
  217. user_id: Optional[str],
  218. ) -> None:
  219. if not link.db_purchase_plan_id:
  220. return
  221. db_plan = (
  222. db.query(DbPurchasePlan)
  223. .filter(
  224. DbPurchasePlan.id == link.db_purchase_plan_id,
  225. DbPurchasePlan.tenant_no == tenant_no,
  226. DbPurchasePlan.del_flag == 0,
  227. )
  228. .first()
  229. )
  230. if not db_plan:
  231. return
  232. qty = _resolve_link_contributed_qty(link, inventory)
  233. if qty:
  234. _apply_plan_quantity_delta(db_plan, -qty, user_id=user_id)
  235. def _generate_batch_no(db: Session, tenant_no: str) -> str:
  236. rows = (
  237. db.query(DbInboundOutbound.batch_no)
  238. .filter(
  239. DbInboundOutbound.tenant_no == tenant_no,
  240. DbInboundOutbound.del_flag == 0,
  241. DbInboundOutbound.batch_no.like("CRKDH%"),
  242. )
  243. .all()
  244. )
  245. max_seq = 0
  246. pattern = re.compile(r"^CRKDH(\d+)$")
  247. for (batch_no,) in rows:
  248. if batch_no:
  249. match = pattern.match(batch_no)
  250. if match:
  251. max_seq = max(max_seq, int(match.group(1)))
  252. return f"CRKDH{max_seq + 1:07d}"
  253. def find_monthly_db_plan(
  254. db: Session,
  255. tenant_no: str,
  256. plan_date: date,
  257. business_type: str,
  258. customer_name: str,
  259. product_id: str,
  260. specification: Optional[str],
  261. ) -> Optional[DbPurchasePlan]:
  262. month_start, month_end = _month_range(plan_date)
  263. customer = (customer_name or "").strip()
  264. spec = (specification or "").strip()
  265. query = db.query(DbPurchasePlan).filter(
  266. DbPurchasePlan.tenant_no == tenant_no,
  267. DbPurchasePlan.del_flag == 0,
  268. DbPurchasePlan.business_type == business_type,
  269. DbPurchasePlan.product_id == product_id,
  270. DbPurchasePlan.start_date <= month_end,
  271. DbPurchasePlan.end_date >= month_start,
  272. )
  273. if customer:
  274. query = query.filter(DbPurchasePlan.customer_name == customer)
  275. if spec:
  276. query = query.filter(DbPurchasePlan.specification == spec)
  277. return query.order_by(DbPurchasePlan.create_time.desc()).first()
  278. def _create_db_plan(
  279. db: Session,
  280. tenant_no: str,
  281. plan: PlPlan,
  282. product: PlPlanProduct,
  283. business_type: str,
  284. customer_name: str,
  285. dibang_date: date,
  286. month_start: date,
  287. month_end: date,
  288. user_id: Optional[str],
  289. dept_id: str,
  290. contributed_qty: float,
  291. ) -> DbPurchasePlan:
  292. snapshot = resolve_crm_product(db, tenant_no, product.product_id, require_on_shelf=False)
  293. db_plan = DbPurchasePlan(
  294. id=generate_uuid(),
  295. tenant_no=tenant_no,
  296. plan_no=generate_plan_no(),
  297. plan_date=dibang_date,
  298. start_date=month_start,
  299. end_date=month_end,
  300. business_type=business_type,
  301. planned_quantity=contributed_qty,
  302. vehicle_no=(plan.license_plate or "").strip(),
  303. customer_name=customer_name or None,
  304. remark=f"来源运输计划 {plan.plan_no}",
  305. execution_status=default_execution_status(business_type),
  306. executed_at=None,
  307. last_weighing_slip_id=None,
  308. executed_quantity=0,
  309. auto_generate_enabled=0,
  310. auto_generate_end_month=None,
  311. auto_generate_last_month=None,
  312. create_by=user_id,
  313. create_dept_id=dept_id,
  314. )
  315. apply_product_snapshot_to_plan(db_plan, snapshot)
  316. db.add(db_plan)
  317. db.flush()
  318. return db_plan
  319. def _create_db_inventory(
  320. db: Session,
  321. tenant_no: str,
  322. plan: PlPlan,
  323. product: PlPlanProduct,
  324. db_plan: DbPurchasePlan,
  325. business_type: str,
  326. customer_name: str,
  327. license_plate: str,
  328. dibang_date: date,
  329. user_id: Optional[str],
  330. dept_id: str,
  331. ) -> DbInboundOutbound:
  332. snapshot = resolve_crm_product(db, tenant_no, product.product_id, require_on_shelf=False)
  333. status_value = "待入库" if business_type == "入库" else "待出库"
  334. inventory_plan_no = ((db_plan.plan_no if db_plan else plan.plan_no) or "").strip() or None
  335. remark = f"运输计划 {plan.plan_no}"
  336. purchase_plan_id = db_plan.id if db_plan else None
  337. inventory = DbInboundOutbound(
  338. id=next_inbound_outbound_id(db, tenant_no),
  339. tenant_no=tenant_no,
  340. date=dibang_date,
  341. partner_company=customer_name,
  342. business_type=business_type,
  343. quantity=Decimal(str(_product_quantity(product))),
  344. plan_no=inventory_plan_no,
  345. batch_no=_generate_batch_no(db, tenant_no),
  346. vehicle_no=(license_plate or "").strip(),
  347. status=status_value,
  348. remark=remark,
  349. purchase_plan_id=purchase_plan_id,
  350. create_by=user_id,
  351. create_dept_id=dept_id,
  352. )
  353. apply_product_snapshot_to_inventory(inventory, snapshot)
  354. db.add(inventory)
  355. db.flush()
  356. return inventory
  357. # 仅这些状态的出入库单可在「再次安排+勾地磅」时软删后重建
  358. _REVOKABLE_INVENTORY_STATUSES = frozenset({"待入库", "待出库", "待三方填写", "待处理"})
  359. def _inventory_has_weighing_slip(db: Session, inventory_id: str) -> bool:
  360. return (
  361. db.query(DbWeighingSlip.id)
  362. .filter(
  363. DbWeighingSlip.inbound_outbound_id == inventory_id,
  364. DbWeighingSlip.del_flag == 0,
  365. DbWeighingSlip.is_voided == 0,
  366. )
  367. .first()
  368. is not None
  369. )
  370. def revoke_regeneratable_dibang_for_transport(
  371. db: Session,
  372. *,
  373. tenant_no: str,
  374. transport_id: str,
  375. user_id: Optional[str],
  376. ) -> None:
  377. """再次勾选地磅安排前:软删本运输单此前生成的、仍可作废的出入库单及关联。
  378. - 不删除地磅月计划(可能被其他业务复用)
  379. - 若出入库单已完成的/已有关联磅单 → 阻断并提示人工处理
  380. """
  381. links = (
  382. db.query(PlTransportDbLink)
  383. .filter(
  384. PlTransportDbLink.tenant_no == tenant_no,
  385. PlTransportDbLink.transport_id == transport_id,
  386. PlTransportDbLink.del_flag == 0,
  387. )
  388. .all()
  389. )
  390. if not links:
  391. return
  392. blocking: list[str] = []
  393. for link in links:
  394. inventory = (
  395. db.query(DbInboundOutbound)
  396. .filter(
  397. DbInboundOutbound.id == link.db_inventory_id,
  398. DbInboundOutbound.tenant_no == tenant_no,
  399. DbInboundOutbound.del_flag == 0,
  400. )
  401. .first()
  402. )
  403. if not inventory:
  404. link.del_flag = 1
  405. link.update_by = user_id
  406. continue
  407. label = inventory.batch_no or inventory.id
  408. status = (inventory.status or "").strip()
  409. if status and status not in _REVOKABLE_INVENTORY_STATUSES:
  410. blocking.append(f"{label}(状态:{status})")
  411. continue
  412. if _inventory_has_weighing_slip(db, inventory.id):
  413. blocking.append(f"{label}(已关联地磅单)")
  414. if blocking:
  415. raise ValueError(
  416. "以下出入库单已执行或已关联地磅,无法随运输安排自动重建:"
  417. + ";".join(blocking)
  418. + "。请在地磅管理中处理后再安排。"
  419. )
  420. for link in links:
  421. inventory = (
  422. db.query(DbInboundOutbound)
  423. .filter(
  424. DbInboundOutbound.id == link.db_inventory_id,
  425. DbInboundOutbound.tenant_no == tenant_no,
  426. DbInboundOutbound.del_flag == 0,
  427. )
  428. .first()
  429. )
  430. if inventory:
  431. _subtract_plan_contribution(
  432. db,
  433. tenant_no=tenant_no,
  434. link=link,
  435. inventory=inventory,
  436. user_id=user_id,
  437. )
  438. inventory.del_flag = 1
  439. inventory.update_by = user_id
  440. link.del_flag = 1
  441. link.update_by = user_id
  442. db.flush()
  443. def _inventory_date_editable(db: Session, inventory: DbInboundOutbound) -> bool:
  444. status = (inventory.status or "").strip()
  445. if status and status not in _REVOKABLE_INVENTORY_STATUSES:
  446. return False
  447. if _inventory_has_weighing_slip(db, inventory.id):
  448. return False
  449. return True
  450. def _resolve_link_dibang_date(
  451. plan: PlPlan,
  452. link: PlTransportDbLink,
  453. load: Optional[PlPlanLoad],
  454. deliveries: list[PlPlanDelivery],
  455. ) -> date:
  456. stop_type = (link.stop_type or "").strip()
  457. if stop_type == "load":
  458. return _resolve_stop_dibang_date(
  459. plan,
  460. stop_type="load",
  461. load_time=load.load_time if load else None,
  462. )
  463. if stop_type == "delivery":
  464. delivery = next((row for row in deliveries if row.id == link.stop_id), None)
  465. if delivery is None and len(deliveries) == 1:
  466. delivery = deliveries[0]
  467. return _resolve_stop_dibang_date(
  468. plan,
  469. stop_type="delivery",
  470. delivery_time=delivery.delivery_time if delivery else None,
  471. )
  472. return _resolve_dibang_date(plan)
  473. def _ensure_monthly_db_plan_for_inventory(
  474. db: Session,
  475. *,
  476. tenant_no: str,
  477. plan: PlPlan,
  478. inventory: DbInboundOutbound,
  479. dibang_date: date,
  480. contributed_qty: float,
  481. user_id: Optional[str],
  482. dept_id: str,
  483. ) -> DbPurchasePlan:
  484. business_type = (inventory.business_type or "").strip()
  485. partner = (inventory.partner_company or "").strip()
  486. product_id = (inventory.product_id or "").strip()
  487. if not business_type or not product_id:
  488. raise ValueError("出入库单缺少业务类型或产品,无法改挂地磅月计划")
  489. existing = find_monthly_db_plan(
  490. db,
  491. tenant_no,
  492. dibang_date,
  493. business_type,
  494. partner,
  495. product_id,
  496. inventory.specification,
  497. )
  498. if existing:
  499. if contributed_qty:
  500. _apply_plan_quantity_delta(existing, contributed_qty, user_id=user_id)
  501. return existing
  502. month_start, month_end = _month_range(dibang_date)
  503. product = PlPlanProduct(
  504. id=generate_uuid(),
  505. product_id=product_id,
  506. product_name=inventory.product or "",
  507. model=inventory.specification,
  508. unit="吨",
  509. quantity=Decimal(str(contributed_qty or inventory.quantity or 0)),
  510. )
  511. return _create_db_plan(
  512. db,
  513. tenant_no,
  514. plan,
  515. product,
  516. business_type,
  517. partner,
  518. dibang_date,
  519. month_start,
  520. month_end,
  521. user_id,
  522. dept_id,
  523. contributed_qty or float(inventory.quantity or 0),
  524. )
  525. def sync_linked_dibang_dates_for_plan(
  526. db: Session,
  527. *,
  528. tenant_no: str,
  529. plan: PlPlan,
  530. user_id: Optional[str],
  531. dept_id: str = "",
  532. ) -> list[str]:
  533. """计划保存后按当前段次/计划日期回写可编辑出入库单日期;跨月则改挂月计划。
  534. 不可回写时不抛错,返回 warnings。
  535. """
  536. links = (
  537. db.query(PlTransportDbLink)
  538. .filter(
  539. PlTransportDbLink.tenant_no == tenant_no,
  540. PlTransportDbLink.plan_id == plan.id,
  541. PlTransportDbLink.del_flag == 0,
  542. )
  543. .all()
  544. )
  545. if not links:
  546. return []
  547. load = (
  548. db.query(PlPlanLoad)
  549. .filter(
  550. PlPlanLoad.plan_id == plan.id,
  551. PlPlanLoad.tenant_no == tenant_no,
  552. PlPlanLoad.del_flag == 0,
  553. )
  554. .first()
  555. )
  556. deliveries = (
  557. db.query(PlPlanDelivery)
  558. .filter(
  559. PlPlanDelivery.plan_id == plan.id,
  560. PlPlanDelivery.tenant_no == tenant_no,
  561. PlPlanDelivery.del_flag == 0,
  562. )
  563. .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
  564. .all()
  565. )
  566. warnings: list[str] = []
  567. for link in links:
  568. inventory = (
  569. db.query(DbInboundOutbound)
  570. .filter(
  571. DbInboundOutbound.id == link.db_inventory_id,
  572. DbInboundOutbound.tenant_no == tenant_no,
  573. DbInboundOutbound.del_flag == 0,
  574. )
  575. .first()
  576. )
  577. if not inventory:
  578. continue
  579. new_date = _resolve_link_dibang_date(plan, link, load, deliveries)
  580. old_date = _normalize_to_date(inventory.date)
  581. if old_date == new_date:
  582. continue
  583. label = inventory.batch_no or inventory.id
  584. if not _inventory_date_editable(db, inventory):
  585. status = (inventory.status or "").strip() or "未知状态"
  586. if _inventory_has_weighing_slip(db, inventory.id):
  587. reason = "已关联地磅单"
  588. else:
  589. reason = f"状态:{status}"
  590. warnings.append(
  591. f"出入库单 {label}({reason})未同步日期至 {new_date.isoformat()},请在地磅管理中手工处理"
  592. )
  593. continue
  594. same_month = (
  595. old_date is not None
  596. and old_date.year == new_date.year
  597. and old_date.month == new_date.month
  598. )
  599. inventory.date = new_date
  600. inventory.update_by = user_id
  601. if same_month:
  602. continue
  603. qty = _resolve_link_contributed_qty(link, inventory)
  604. _subtract_plan_contribution(
  605. db,
  606. tenant_no=tenant_no,
  607. link=link,
  608. inventory=inventory,
  609. user_id=user_id,
  610. )
  611. try:
  612. new_db_plan = _ensure_monthly_db_plan_for_inventory(
  613. db,
  614. tenant_no=tenant_no,
  615. plan=plan,
  616. inventory=inventory,
  617. dibang_date=new_date,
  618. contributed_qty=qty,
  619. user_id=user_id,
  620. dept_id=dept_id or "",
  621. )
  622. except ValueError as exc:
  623. warnings.append(f"出入库单 {label} 跨月改挂失败:{exc}")
  624. continue
  625. link.db_purchase_plan_id = new_db_plan.id
  626. link.update_by = user_id
  627. inventory.purchase_plan_id = new_db_plan.id
  628. inventory.plan_no = (new_db_plan.plan_no or "").strip() or inventory.plan_no
  629. db.flush()
  630. return warnings
  631. def _sync_stop_inventory_links(
  632. db: Session,
  633. *,
  634. tenant_no: str,
  635. plan: PlPlan,
  636. transport: PlTransport,
  637. stop_type: str,
  638. stop_id: str,
  639. partner: str,
  640. business_type: str,
  641. dibang_date: date,
  642. stop_products: list[_StopDibangProduct],
  643. user_id: Optional[str],
  644. dept_id: str,
  645. ) -> list[DibangInventoryLinkResult]:
  646. if not partner:
  647. raise ValueError("无法解析往来单位,请检查段客户信息")
  648. month_start, month_end = _month_range(dibang_date)
  649. license_plate = (transport.license_plate or plan.license_plate or "").strip()
  650. results: list[DibangInventoryLinkResult] = []
  651. for product in stop_products:
  652. if not product.product_id:
  653. raise ValueError(f"产品【{product.product_name}】未关联 CRM 产品,无法生成地磅出入库单")
  654. contributed_qty = product.quantity
  655. if contributed_qty <= 0:
  656. continue
  657. plan_product = PlPlanProduct(
  658. id=product.plan_product_id,
  659. product_id=product.product_id,
  660. product_name=product.product_name,
  661. model=product.model,
  662. unit=product.unit,
  663. quantity=Decimal(str(contributed_qty)),
  664. )
  665. db_plan = find_monthly_db_plan(
  666. db,
  667. tenant_no,
  668. dibang_date,
  669. business_type,
  670. partner,
  671. product.product_id,
  672. product.model,
  673. )
  674. if not db_plan:
  675. db_plan = _create_db_plan(
  676. db,
  677. tenant_no,
  678. plan,
  679. plan_product,
  680. business_type,
  681. partner,
  682. dibang_date,
  683. month_start,
  684. month_end,
  685. user_id,
  686. dept_id,
  687. contributed_qty,
  688. )
  689. else:
  690. _apply_plan_quantity_delta(db_plan, contributed_qty, user_id=user_id)
  691. inventory = _create_db_inventory(
  692. db,
  693. tenant_no,
  694. plan,
  695. plan_product,
  696. db_plan,
  697. business_type,
  698. partner,
  699. license_plate,
  700. dibang_date,
  701. user_id,
  702. dept_id,
  703. )
  704. link = PlTransportDbLink(
  705. id=generate_uuid(),
  706. tenant_no=tenant_no,
  707. transport_id=transport.id,
  708. plan_id=plan.id,
  709. plan_product_id=product.plan_product_id,
  710. stop_type=stop_type,
  711. stop_id=stop_id,
  712. db_purchase_plan_id=db_plan.id if db_plan else "",
  713. db_inventory_id=inventory.id,
  714. contributed_qty=Decimal(str(contributed_qty)),
  715. remark=f"运输计划 {plan.plan_no}",
  716. create_by=user_id,
  717. create_dept_id=dept_id,
  718. )
  719. db.add(link)
  720. results.append(
  721. DibangInventoryLinkResult(
  722. plan_product_id=product.plan_product_id,
  723. product_name=product.product_name or "",
  724. db_purchase_plan_id=db_plan.id if db_plan else "",
  725. db_inventory_id=inventory.id,
  726. batch_no=inventory.batch_no or "",
  727. plan_no=(db_plan.plan_no if db_plan else plan.plan_no) or "",
  728. )
  729. )
  730. return results
  731. def _resolve_stop_dibang_date(
  732. plan: PlPlan,
  733. *,
  734. stop_type: str,
  735. load_time: date | datetime | str | None = None,
  736. delivery_time: date | datetime | str | None = None,
  737. ) -> date:
  738. if stop_type == "load":
  739. resolved = _normalize_to_date(load_time)
  740. if resolved is None:
  741. resolved = _normalize_to_date(plan.pickup_time)
  742. else:
  743. resolved = _normalize_to_date(delivery_time)
  744. if resolved is None:
  745. resolved = _normalize_to_date(plan.delivery_time)
  746. return resolved or _normalize_plan_date(plan)
  747. def _load_stop_products(
  748. db: Session,
  749. tenant_no: str,
  750. load_id: str,
  751. ) -> list[_StopDibangProduct]:
  752. rows = (
  753. db.query(PlPlanLoadProduct)
  754. .filter(
  755. PlPlanLoadProduct.load_id == load_id,
  756. PlPlanLoadProduct.tenant_no == tenant_no,
  757. PlPlanLoadProduct.del_flag == 0,
  758. )
  759. .order_by(PlPlanLoadProduct.order_num.asc(), PlPlanLoadProduct.create_time.asc())
  760. .all()
  761. )
  762. return [
  763. _StopDibangProduct(
  764. plan_product_id=row.plan_product_id,
  765. product_id=row.product_id,
  766. product_name=row.product_name or "",
  767. model=row.model,
  768. unit=row.unit or "吨",
  769. quantity=_stop_product_quantity(row),
  770. )
  771. for row in rows
  772. ]
  773. def _delivery_stop_products(
  774. db: Session,
  775. tenant_no: str,
  776. delivery_id: str,
  777. ) -> list[_StopDibangProduct]:
  778. rows = (
  779. db.query(PlPlanDeliveryProduct)
  780. .filter(
  781. PlPlanDeliveryProduct.delivery_id == delivery_id,
  782. PlPlanDeliveryProduct.tenant_no == tenant_no,
  783. PlPlanDeliveryProduct.del_flag == 0,
  784. )
  785. .order_by(PlPlanDeliveryProduct.order_num.asc(), PlPlanDeliveryProduct.create_time.asc())
  786. .all()
  787. )
  788. return [
  789. _StopDibangProduct(
  790. plan_product_id=row.plan_product_id,
  791. product_id=row.product_id,
  792. product_name=row.product_name or "",
  793. model=row.model,
  794. unit=row.unit or "吨",
  795. quantity=_stop_product_quantity(row),
  796. )
  797. for row in rows
  798. ]
  799. def sync_dibang_for_plan_stops(
  800. db: Session,
  801. *,
  802. tenant_no: str,
  803. plan: PlPlan,
  804. transport: PlTransport,
  805. stop_options: list[StopDibangOption],
  806. user_id: Optional[str],
  807. dept_id: str,
  808. ) -> list[DibangInventoryLinkResult]:
  809. """按装货/送货段勾选生成地磅计划与出入库单。"""
  810. apply_stop_dibang_options(
  811. db,
  812. tenant_no=tenant_no,
  813. plan_id=plan.id,
  814. stop_options=stop_options,
  815. user_id=user_id,
  816. )
  817. option_map = {(item.stop_type, item.stop_id): item for item in stop_options}
  818. results: list[DibangInventoryLinkResult] = []
  819. deliveries = (
  820. db.query(PlPlanDelivery)
  821. .filter(
  822. PlPlanDelivery.plan_id == plan.id,
  823. PlPlanDelivery.tenant_no == tenant_no,
  824. PlPlanDelivery.del_flag == 0,
  825. )
  826. .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
  827. .all()
  828. )
  829. load = (
  830. db.query(PlPlanLoad)
  831. .filter(
  832. PlPlanLoad.plan_id == plan.id,
  833. PlPlanLoad.tenant_no == tenant_no,
  834. PlPlanLoad.del_flag == 0,
  835. )
  836. .first()
  837. )
  838. if load:
  839. option = option_map.get(("load", load.id))
  840. if option and option.generate_dibang:
  841. business_type = _resolve_stop_business_type(load, "load")
  842. if not business_type:
  843. raise ValueError("装货段未配置地磅方向,无法生成地磅单")
  844. stop_products = _load_stop_products(db, tenant_no, load.id)
  845. if not stop_products:
  846. raise ValueError("装货段无产品明细,无法生成地磅单")
  847. partner = resolve_stop_dibang_partner("load", load, deliveries)
  848. if not partner:
  849. raise ValueError("无法解析装货段地磅往来单位,请先配置送货客户")
  850. results.extend(
  851. _sync_stop_inventory_links(
  852. db,
  853. tenant_no=tenant_no,
  854. plan=plan,
  855. transport=transport,
  856. stop_type="load",
  857. stop_id=load.id,
  858. partner=partner,
  859. business_type=business_type,
  860. dibang_date=_resolve_stop_dibang_date(
  861. plan,
  862. stop_type="load",
  863. load_time=load.load_time,
  864. ),
  865. stop_products=stop_products,
  866. user_id=user_id,
  867. dept_id=dept_id,
  868. )
  869. )
  870. for delivery in deliveries:
  871. option = option_map.get(("delivery", delivery.id))
  872. if not option or not option.generate_dibang:
  873. continue
  874. business_type = _resolve_stop_business_type(delivery, "delivery")
  875. if not business_type:
  876. raise ValueError(f"送货段【{delivery.customer_name or delivery.seq}】未配置地磅方向,无法生成地磅单")
  877. stop_products = _delivery_stop_products(db, tenant_no, delivery.id)
  878. if not stop_products:
  879. raise ValueError(f"送货段【{delivery.customer_name or delivery.seq}】无产品明细,无法生成地磅单")
  880. partner = resolve_stop_dibang_partner("delivery", load, deliveries)
  881. if not partner:
  882. raise ValueError("无法解析送货段地磅往来单位,请先配置装货客户")
  883. results.extend(
  884. _sync_stop_inventory_links(
  885. db,
  886. tenant_no=tenant_no,
  887. plan=plan,
  888. transport=transport,
  889. stop_type="delivery",
  890. stop_id=delivery.id,
  891. partner=partner,
  892. business_type=business_type,
  893. dibang_date=_resolve_stop_dibang_date(
  894. plan,
  895. stop_type="delivery",
  896. delivery_time=delivery.delivery_time,
  897. ),
  898. stop_products=stop_products,
  899. user_id=user_id,
  900. dept_id=dept_id,
  901. )
  902. )
  903. return results
  904. def _sync_transport_inventory_links(
  905. db: Session,
  906. *,
  907. tenant_no: str,
  908. plan: PlPlan,
  909. products: list[PlPlanProduct],
  910. transport: PlTransport,
  911. user_id: Optional[str],
  912. dept_id: str,
  913. ) -> list[DibangInventoryLinkResult]:
  914. business_type = _plan_type_to_business_type(plan.plan_type)
  915. if not business_type:
  916. return []
  917. partner = resolve_partner_company(plan, plan.plan_type)
  918. if not partner:
  919. raise ValueError("无法解析往来单位,请检查合同供方/需方信息")
  920. dibang_date = _resolve_dibang_date(plan)
  921. month_start, month_end = _month_range(dibang_date)
  922. license_plate = (transport.license_plate or plan.license_plate or "").strip()
  923. results: list[DibangInventoryLinkResult] = []
  924. for product in products:
  925. if not product.product_id:
  926. raise ValueError(f"产品【{product.product_name}】未关联 CRM 产品,无法生成地磅出入库单")
  927. contributed_qty = _product_quantity(product)
  928. db_plan = find_monthly_db_plan(
  929. db,
  930. tenant_no,
  931. dibang_date,
  932. business_type,
  933. partner,
  934. product.product_id,
  935. product.model,
  936. )
  937. if not db_plan:
  938. db_plan = _create_db_plan(
  939. db,
  940. tenant_no,
  941. plan,
  942. product,
  943. business_type,
  944. partner,
  945. dibang_date,
  946. month_start,
  947. month_end,
  948. user_id,
  949. dept_id,
  950. contributed_qty,
  951. )
  952. else:
  953. _apply_plan_quantity_delta(db_plan, contributed_qty, user_id=user_id)
  954. inventory = _create_db_inventory(
  955. db,
  956. tenant_no,
  957. plan,
  958. product,
  959. db_plan,
  960. business_type,
  961. partner,
  962. license_plate,
  963. dibang_date,
  964. user_id,
  965. dept_id,
  966. )
  967. link = PlTransportDbLink(
  968. id=generate_uuid(),
  969. tenant_no=tenant_no,
  970. transport_id=transport.id,
  971. plan_id=plan.id,
  972. plan_product_id=product.id,
  973. stop_type=None,
  974. stop_id=None,
  975. db_purchase_plan_id=db_plan.id if db_plan else "",
  976. db_inventory_id=inventory.id,
  977. contributed_qty=Decimal(str(contributed_qty)),
  978. remark=f"运输计划 {plan.plan_no}",
  979. create_by=user_id,
  980. create_dept_id=dept_id,
  981. )
  982. db.add(link)
  983. results.append(
  984. DibangInventoryLinkResult(
  985. plan_product_id=product.id,
  986. product_name=product.product_name or "",
  987. db_purchase_plan_id=db_plan.id if db_plan else "",
  988. db_inventory_id=inventory.id,
  989. batch_no=inventory.batch_no or "",
  990. plan_no=(db_plan.plan_no if db_plan else plan.plan_no) or "",
  991. )
  992. )
  993. return results
  994. def sync_dibang_for_transport(
  995. db: Session,
  996. *,
  997. tenant_no: str,
  998. plan: PlPlan,
  999. products: list[PlPlanProduct],
  1000. transport: PlTransport,
  1001. user_id: Optional[str],
  1002. dept_id: str,
  1003. ) -> list[DibangInventoryLinkResult]:
  1004. """勾选「生成地磅单」:按产品生成/复用地磅计划,并创建关联出入库单。"""
  1005. return _sync_transport_inventory_links(
  1006. db,
  1007. tenant_no=tenant_no,
  1008. plan=plan,
  1009. products=products,
  1010. transport=transport,
  1011. user_id=user_id,
  1012. dept_id=dept_id,
  1013. )