pl_plan_contract_gap.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. from __future__ import annotations
  2. from datetime import date, datetime
  3. from typing import List, Optional, TYPE_CHECKING
  4. from fastapi import HTTPException
  5. from sqlalchemy import and_, exists, func, or_, select
  6. from sqlalchemy.orm import Session
  7. from app.core.utils import generate_uuid
  8. from app.models.pl_models import (
  9. PlPlan,
  10. PlPlanContract,
  11. PlPlanDelivery,
  12. PlPlanLoad,
  13. PlPlanProduct,
  14. )
  15. from app.schemas.pl.plan import (
  16. PlanContractGapRead,
  17. PlanContractLink,
  18. PlanDeliveryRead,
  19. PlanLoadRead,
  20. PlanProductRead,
  21. )
  22. from app.services.pl.plan_list_filters import (
  23. _parse_date,
  24. build_plan_column_filters,
  25. plan_delivery_time_exists,
  26. )
  27. from app.services.pl_data_scope import apply_pl_scope
  28. from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES, is_internal_customer_name
  29. from app.services.pl_plan_service import (
  30. PLAN_CONTRACT_SIDE_DEMANDER,
  31. PLAN_CONTRACT_SIDE_SUPPLIER,
  32. _normalize_contract_links,
  33. assert_contract_selectable_for_plan,
  34. load_plan_contracts_grouped,
  35. replace_plan_contracts,
  36. )
  37. from app.services.pl_plan_stop_service import (
  38. PLAN_CONTRACT_SIDE_DELIVERY,
  39. PLAN_CONTRACT_SIDE_LOAD,
  40. assert_at_most_one_contract,
  41. collect_unique_delivery_date_strings,
  42. load_plan_stops_grouped,
  43. )
  44. from app.services.pl_product_display import load_crm_product_alias_maps, resolve_product_display_names
  45. if TYPE_CHECKING:
  46. from app.core.db_auth import DbUserContext
  47. def stop_contract_gap(customer_name: Optional[str], contract_count: int) -> bool:
  48. """段次客户非内置且未关联合同时视为缺口。"""
  49. return not is_internal_customer_name(customer_name) and contract_count <= 0
  50. def side_is_exempt(name: Optional[str]) -> bool:
  51. return is_internal_customer_name(name)
  52. def compute_contract_gap_sides(
  53. *,
  54. supplier_name: Optional[str],
  55. customer_name: Optional[str],
  56. supplier_contract_count: int,
  57. demander_contract_count: int,
  58. ) -> List[str]:
  59. gaps: List[str] = []
  60. if stop_contract_gap(supplier_name, supplier_contract_count):
  61. gaps.append("supplier")
  62. if stop_contract_gap(customer_name, demander_contract_count):
  63. gaps.append("demander")
  64. return gaps
  65. def compute_plan_gap_sides_from_stops(
  66. load_read: Optional[PlanLoadRead],
  67. delivery_reads: list[PlanDeliveryRead],
  68. *,
  69. supplier_name: Optional[str] = None,
  70. customer_name: Optional[str] = None,
  71. supplier_contract_count: int = 0,
  72. demander_contract_count: int = 0,
  73. ) -> list[str]:
  74. """按段次汇总计划是否仍有合同缺口(supplier=装货侧,demander=送货侧)。"""
  75. has_load_gap = False
  76. has_delivery_gap = False
  77. if load_read is not None:
  78. has_load_gap = stop_contract_gap(load_read.customer_name, len(load_read.contracts or []))
  79. elif stop_contract_gap(supplier_name, supplier_contract_count):
  80. has_load_gap = True
  81. if delivery_reads:
  82. has_delivery_gap = any(
  83. stop_contract_gap(item.customer_name, len(item.contracts or []))
  84. for item in delivery_reads
  85. )
  86. elif stop_contract_gap(customer_name, demander_contract_count):
  87. has_delivery_gap = True
  88. gaps: list[str] = []
  89. if has_load_gap:
  90. gaps.append("supplier")
  91. if has_delivery_gap:
  92. gaps.append("demander")
  93. return gaps
  94. def _stop_contract_exists(tenant_no: str, plan_id_expr, stop_id_expr, legacy_sides: tuple[str, ...]):
  95. return exists(
  96. select(PlPlanContract.id).where(
  97. PlPlanContract.plan_id == plan_id_expr,
  98. PlPlanContract.tenant_no == tenant_no,
  99. PlPlanContract.del_flag == 0,
  100. or_(
  101. PlPlanContract.stop_id == stop_id_expr,
  102. and_(
  103. or_(PlPlanContract.stop_id.is_(None), PlPlanContract.stop_id == ""),
  104. PlPlanContract.side.in_(legacy_sides),
  105. ),
  106. ),
  107. )
  108. )
  109. def _load_stop_gap_expr(tenant_no: str):
  110. internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
  111. customer_not_internal = or_(
  112. PlPlanLoad.customer_name.is_(None),
  113. PlPlanLoad.customer_name == "",
  114. ~PlPlanLoad.customer_name.in_(internal_names),
  115. )
  116. return exists(
  117. select(PlPlanLoad.id).where(
  118. PlPlanLoad.plan_id == PlPlan.id,
  119. PlPlanLoad.tenant_no == tenant_no,
  120. PlPlanLoad.del_flag == 0,
  121. customer_not_internal,
  122. ~_stop_contract_exists(
  123. tenant_no,
  124. PlPlan.id,
  125. PlPlanLoad.id,
  126. (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER),
  127. ),
  128. )
  129. )
  130. def _delivery_stop_gap_expr(tenant_no: str):
  131. internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
  132. customer_not_internal = or_(
  133. PlPlanDelivery.customer_name.is_(None),
  134. PlPlanDelivery.customer_name == "",
  135. ~PlPlanDelivery.customer_name.in_(internal_names),
  136. )
  137. return exists(
  138. select(PlPlanDelivery.id).where(
  139. PlPlanDelivery.plan_id == PlPlan.id,
  140. PlPlanDelivery.tenant_no == tenant_no,
  141. PlPlanDelivery.del_flag == 0,
  142. customer_not_internal,
  143. ~_stop_contract_exists(
  144. tenant_no,
  145. PlPlan.id,
  146. PlPlanDelivery.id,
  147. (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER),
  148. ),
  149. )
  150. )
  151. def _legacy_supplier_gap_expr(tenant_no: str):
  152. internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
  153. has_load = exists(
  154. select(PlPlanLoad.id).where(
  155. PlPlanLoad.plan_id == PlPlan.id,
  156. PlPlanLoad.tenant_no == tenant_no,
  157. PlPlanLoad.del_flag == 0,
  158. )
  159. )
  160. supplier_not_internal = or_(
  161. PlPlan.supplier_name.is_(None),
  162. PlPlan.supplier_name == "",
  163. ~PlPlan.supplier_name.in_(internal_names),
  164. )
  165. return and_(
  166. ~has_load,
  167. supplier_not_internal,
  168. ~_stop_contract_exists(
  169. tenant_no,
  170. PlPlan.id,
  171. PlPlan.id,
  172. (PLAN_CONTRACT_SIDE_SUPPLIER,),
  173. ),
  174. )
  175. def _legacy_demander_gap_expr(tenant_no: str):
  176. internal_names = tuple(INTERNAL_CUSTOMER_NAMES)
  177. has_delivery = exists(
  178. select(PlPlanDelivery.id).where(
  179. PlPlanDelivery.plan_id == PlPlan.id,
  180. PlPlanDelivery.tenant_no == tenant_no,
  181. PlPlanDelivery.del_flag == 0,
  182. )
  183. )
  184. customer_not_internal = or_(
  185. PlPlan.customer_name.is_(None),
  186. PlPlan.customer_name == "",
  187. ~PlPlan.customer_name.in_(internal_names),
  188. )
  189. return and_(
  190. ~has_delivery,
  191. customer_not_internal,
  192. ~_stop_contract_exists(
  193. tenant_no,
  194. PlPlan.id,
  195. PlPlan.id,
  196. (PLAN_CONTRACT_SIDE_DEMANDER,),
  197. ),
  198. )
  199. def plan_has_contract_gap_exists(tenant_no: str):
  200. """列表筛选:任一段次(或旧计划级)存在合同缺口。"""
  201. return or_(
  202. _load_stop_gap_expr(tenant_no),
  203. _delivery_stop_gap_expr(tenant_no),
  204. _legacy_supplier_gap_expr(tenant_no),
  205. _legacy_demander_gap_expr(tenant_no),
  206. )
  207. def _supplier_gap_expr(tenant_no: str):
  208. return or_(_load_stop_gap_expr(tenant_no), _legacy_supplier_gap_expr(tenant_no))
  209. def _demander_gap_expr(tenant_no: str):
  210. return or_(_delivery_stop_gap_expr(tenant_no), _legacy_demander_gap_expr(tenant_no))
  211. def build_plan_contract_gap_read(
  212. plan: PlPlan,
  213. *,
  214. supplier_contracts: list[PlanContractLink],
  215. demander_contracts: list[PlanContractLink],
  216. products: Optional[list[PlanProductRead]] = None,
  217. load_read: Optional[PlanLoadRead] = None,
  218. delivery_reads: Optional[list[PlanDeliveryRead]] = None,
  219. ) -> PlanContractGapRead:
  220. deliveries = list(delivery_reads or [])
  221. gap_sides = compute_plan_gap_sides_from_stops(
  222. load_read,
  223. deliveries,
  224. supplier_name=plan.supplier_name,
  225. customer_name=plan.customer_name,
  226. supplier_contract_count=len(supplier_contracts),
  227. demander_contract_count=len(demander_contracts),
  228. )
  229. return PlanContractGapRead(
  230. id=plan.id,
  231. planNo=plan.plan_no,
  232. planDate=plan.plan_date,
  233. planType=plan.plan_type,
  234. followStatus=plan.follow_status,
  235. supplierId=plan.supplier_id,
  236. supplierName=plan.supplier_name,
  237. customerId=plan.customer_id,
  238. customerName=plan.customer_name,
  239. gapSides=gap_sides,
  240. supplierContractExempt=side_is_exempt(plan.supplier_name),
  241. demanderContractExempt=side_is_exempt(plan.customer_name),
  242. supplierContracts=supplier_contracts,
  243. demanderContracts=demander_contracts,
  244. products=list(products or []),
  245. load=load_read,
  246. deliveries=deliveries,
  247. deliveryTimes=collect_unique_delivery_date_strings(deliveries),
  248. )
  249. def _load_products_by_plan_ids(
  250. db: Session,
  251. plan_ids: list[str],
  252. *,
  253. tenant_no: str,
  254. ) -> dict[str, list[PlanProductRead]]:
  255. if not plan_ids:
  256. return {}
  257. rows = (
  258. db.query(PlPlanProduct)
  259. .filter(
  260. PlPlanProduct.plan_id.in_(plan_ids),
  261. PlPlanProduct.del_flag == 0,
  262. )
  263. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  264. .all()
  265. )
  266. product_ids = {(row.product_id or "").strip() for row in rows if (row.product_id or "").strip()}
  267. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  268. db, tenant_no=tenant_no, product_ids=product_ids
  269. )
  270. out: dict[str, list[PlanProductRead]] = {pid: [] for pid in plan_ids}
  271. for row in rows:
  272. item = PlanProductRead.model_validate(row)
  273. display_name, _alias = resolve_product_display_names(
  274. saved_product_name=row.product_name,
  275. product_id=row.product_id,
  276. standard_by_id=standard_by_id,
  277. alias_options_by_id=alias_options_by_id,
  278. )
  279. item.product_name = display_name or (row.product_name or "")
  280. out.setdefault(row.plan_id, []).append(item)
  281. return out
  282. def _parse_csv(value: Optional[str]) -> List[str]:
  283. if not value:
  284. return []
  285. return [item.strip() for item in str(value).split(",") if item.strip()]
  286. def list_plan_contract_gaps(
  287. db: Session,
  288. *,
  289. tenant_no: str,
  290. user: "DbUserContext",
  291. page: int = 1,
  292. page_size: int = 20,
  293. plan_no: Optional[str] = None,
  294. gap_type: Optional[str] = None,
  295. customer_keyword: Optional[str] = None,
  296. plan_date_from: Optional[date] = None,
  297. plan_date_to: Optional[date] = None,
  298. delivery_time_from: Optional[date] = None,
  299. delivery_time_to: Optional[date] = None,
  300. plan_type: Optional[str] = None,
  301. plan_type_in: Optional[str] = None,
  302. follow_status: Optional[str] = None,
  303. q: Optional[str] = None,
  304. ids: Optional[list] = None,
  305. max_page_size: Optional[int] = None,
  306. sort_field: Optional[str] = None,
  307. sort_order: Optional[str] = None,
  308. ) -> tuple[int, list[PlanContractGapRead]]:
  309. query = db.query(PlPlan).filter(
  310. PlPlan.tenant_no == tenant_no,
  311. PlPlan.del_flag == 0,
  312. PlPlan.follow_status != "cancelled",
  313. plan_has_contract_gap_exists(tenant_no),
  314. )
  315. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  316. gap_vals = [v.lower() for v in _parse_csv(gap_type)]
  317. if len(gap_vals) == 1:
  318. gap_type_norm = gap_vals[0]
  319. if gap_type_norm == "supplier":
  320. query = query.filter(_supplier_gap_expr(tenant_no))
  321. elif gap_type_norm == "demander":
  322. query = query.filter(_demander_gap_expr(tenant_no))
  323. elif gap_type_norm == "both":
  324. query = query.filter(
  325. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  326. )
  327. elif len(gap_vals) > 1:
  328. clauses = []
  329. for gap_type_norm in gap_vals:
  330. if gap_type_norm == "supplier":
  331. clauses.append(_supplier_gap_expr(tenant_no))
  332. elif gap_type_norm == "demander":
  333. clauses.append(_demander_gap_expr(tenant_no))
  334. elif gap_type_norm == "both":
  335. clauses.append(
  336. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  337. )
  338. if clauses:
  339. query = query.filter(or_(*clauses))
  340. follow_statuses = _parse_csv(follow_status)
  341. if len(follow_statuses) == 1:
  342. query = query.filter(PlPlan.follow_status == follow_statuses[0])
  343. elif len(follow_statuses) > 1:
  344. query = query.filter(PlPlan.follow_status.in_(follow_statuses))
  345. if customer_keyword:
  346. kw = customer_keyword.strip()
  347. if kw:
  348. kw_lower = kw.lower()
  349. load_match = exists(
  350. select(PlPlanLoad.id).where(
  351. PlPlanLoad.plan_id == PlPlan.id,
  352. PlPlanLoad.tenant_no == tenant_no,
  353. PlPlanLoad.del_flag == 0,
  354. func.lower(PlPlanLoad.customer_name).contains(kw_lower),
  355. )
  356. )
  357. delivery_match = exists(
  358. select(PlPlanDelivery.id).where(
  359. PlPlanDelivery.plan_id == PlPlan.id,
  360. PlPlanDelivery.tenant_no == tenant_no,
  361. PlPlanDelivery.del_flag == 0,
  362. func.lower(PlPlanDelivery.customer_name).contains(kw_lower),
  363. )
  364. )
  365. query = query.filter(
  366. or_(
  367. func.lower(PlPlan.supplier_name).contains(kw_lower),
  368. func.lower(PlPlan.customer_name).contains(kw_lower),
  369. load_match,
  370. delivery_match,
  371. )
  372. )
  373. column_filters = build_plan_column_filters(
  374. db=db,
  375. tenant_no=tenant_no,
  376. plan_no=plan_no,
  377. plan_type=plan_type,
  378. plan_type_in=plan_type_in,
  379. plan_date_from=plan_date_from,
  380. plan_date_to=plan_date_to,
  381. )
  382. if column_filters:
  383. query = query.filter(*column_filters)
  384. dt_from = _parse_date(delivery_time_from)
  385. dt_to = _parse_date(delivery_time_to)
  386. if dt_from is not None or dt_to is not None:
  387. query = query.filter(
  388. plan_delivery_time_exists(db, tenant_no, d_from=dt_from, d_to=dt_to)
  389. )
  390. if q:
  391. q_kw = q.strip()
  392. if q_kw:
  393. q_lower = q_kw.lower()
  394. load_match = exists(
  395. select(PlPlanLoad.id).where(
  396. PlPlanLoad.plan_id == PlPlan.id,
  397. PlPlanLoad.tenant_no == tenant_no,
  398. PlPlanLoad.del_flag == 0,
  399. func.lower(PlPlanLoad.customer_name).contains(q_lower),
  400. )
  401. )
  402. delivery_match = exists(
  403. select(PlPlanDelivery.id).where(
  404. PlPlanDelivery.plan_id == PlPlan.id,
  405. PlPlanDelivery.tenant_no == tenant_no,
  406. PlPlanDelivery.del_flag == 0,
  407. func.lower(PlPlanDelivery.customer_name).contains(q_lower),
  408. )
  409. )
  410. query = query.filter(
  411. or_(
  412. PlPlan.plan_no.contains(q_kw),
  413. func.lower(PlPlan.supplier_name).contains(q_lower),
  414. func.lower(PlPlan.customer_name).contains(q_lower),
  415. load_match,
  416. delivery_match,
  417. )
  418. )
  419. if ids:
  420. query = query.filter(PlPlan.id.in_(ids))
  421. from app.services.pl.list_sort import order_plan
  422. query = query.order_by(
  423. order_plan(sort_field, sort_order, default=PlPlan.plan_date.desc()),
  424. PlPlan.create_time.desc(),
  425. )
  426. total = query.count()
  427. page = max(1, page)
  428. cap = int(max_page_size) if max_page_size else 500
  429. page_size = min(max(1, page_size), cap)
  430. offset = (page - 1) * page_size
  431. plans = query.offset(offset).limit(page_size).all()
  432. plan_ids = [plan.id for plan in plans]
  433. contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
  434. products_by_plan = _load_products_by_plan_ids(db, plan_ids, tenant_no=tenant_no)
  435. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  436. items: list[PlanContractGapRead] = []
  437. for plan in plans:
  438. supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
  439. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  440. items.append(
  441. build_plan_contract_gap_read(
  442. plan,
  443. supplier_contracts=supplier_contracts,
  444. demander_contracts=demander_contracts,
  445. products=products_by_plan.get(plan.id, []),
  446. load_read=load_read,
  447. delivery_reads=delivery_reads,
  448. )
  449. )
  450. return total, items
  451. def _write_stop_contract_rows(
  452. db: Session,
  453. *,
  454. tenant_no: str,
  455. plan_id: str,
  456. stop_id: str,
  457. side: str,
  458. contracts: list[PlanContractLink],
  459. user_id: Optional[str],
  460. dept_id: Optional[str],
  461. ) -> None:
  462. now = datetime.now()
  463. db.query(PlPlanContract).filter(
  464. PlPlanContract.plan_id == plan_id,
  465. PlPlanContract.tenant_no == tenant_no,
  466. PlPlanContract.del_flag == 0,
  467. PlPlanContract.stop_id == stop_id,
  468. ).update(
  469. {"del_flag": 1, "update_by": user_id, "update_time": now},
  470. synchronize_session=False,
  471. )
  472. order_num = 0
  473. for link in contracts:
  474. order_num += 1
  475. db.add(
  476. PlPlanContract(
  477. id=generate_uuid(),
  478. plan_id=plan_id,
  479. side=side,
  480. stop_id=stop_id,
  481. contract_id=link.contract_id,
  482. contract_no=link.contract_no,
  483. tenant_no=tenant_no,
  484. order_num=order_num,
  485. create_by=user_id,
  486. create_dept_id=dept_id or "",
  487. create_time=now,
  488. update_by=user_id,
  489. update_time=now,
  490. del_flag=0,
  491. )
  492. )
  493. def _clear_legacy_side_contracts(
  494. db: Session,
  495. *,
  496. tenant_no: str,
  497. plan_id: str,
  498. legacy_sides: tuple[str, ...],
  499. user_id: Optional[str],
  500. ) -> None:
  501. now = datetime.now()
  502. db.query(PlPlanContract).filter(
  503. PlPlanContract.plan_id == plan_id,
  504. PlPlanContract.tenant_no == tenant_no,
  505. PlPlanContract.del_flag == 0,
  506. or_(PlPlanContract.stop_id.is_(None), PlPlanContract.stop_id == ""),
  507. PlPlanContract.side.in_(legacy_sides),
  508. ).update(
  509. {"del_flag": 1, "update_by": user_id, "update_time": now},
  510. synchronize_session=False,
  511. )
  512. def update_plan_stop_contracts(
  513. db: Session,
  514. *,
  515. tenant_no: str,
  516. plan: PlPlan,
  517. stop_type: str,
  518. stop_id: str,
  519. contracts: list[PlanContractLink],
  520. user_id: Optional[str],
  521. dept_id: Optional[str],
  522. ) -> PlanContractGapRead:
  523. """保存某装货/送货段的合同关联。"""
  524. if plan.follow_status == "cancelled":
  525. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  526. stop_type_norm = (stop_type or "").strip().lower()
  527. stop_id_norm = (stop_id or "").strip()
  528. if stop_type_norm not in ("load", "delivery") or not stop_id_norm:
  529. raise HTTPException(status_code=422, detail="stopType 必须为 load 或 delivery,且 stopId 不能为空")
  530. if stop_type_norm == "load":
  531. stop = (
  532. db.query(PlPlanLoad)
  533. .filter(
  534. PlPlanLoad.id == stop_id_norm,
  535. PlPlanLoad.plan_id == plan.id,
  536. PlPlanLoad.tenant_no == tenant_no,
  537. PlPlanLoad.del_flag == 0,
  538. )
  539. .first()
  540. )
  541. side = PLAN_CONTRACT_SIDE_LOAD
  542. legacy_sides = (PLAN_CONTRACT_SIDE_SUPPLIER,)
  543. side_label = "装货段合同"
  544. else:
  545. stop = (
  546. db.query(PlPlanDelivery)
  547. .filter(
  548. PlPlanDelivery.id == stop_id_norm,
  549. PlPlanDelivery.plan_id == plan.id,
  550. PlPlanDelivery.tenant_no == tenant_no,
  551. PlPlanDelivery.del_flag == 0,
  552. )
  553. .first()
  554. )
  555. side = PLAN_CONTRACT_SIDE_DELIVERY
  556. legacy_sides = (PLAN_CONTRACT_SIDE_DEMANDER,)
  557. side_label = "送货段合同"
  558. if not stop:
  559. raise HTTPException(status_code=404, detail="段次不存在")
  560. if is_internal_customer_name(stop.customer_name):
  561. raise HTTPException(status_code=400, detail="内置客户无需补充合同")
  562. links = _normalize_contract_links(contracts)
  563. links = assert_at_most_one_contract(links, side_label)
  564. expected_customer_id = stop.customer_id
  565. resolved: list[PlanContractLink] = []
  566. for link in links:
  567. contract = assert_contract_selectable_for_plan(
  568. db,
  569. link.contract_id,
  570. tenant_no,
  571. expected_customer_id=expected_customer_id,
  572. side_label=side_label,
  573. )
  574. resolved.append(
  575. PlanContractLink(
  576. contractId=contract.id,
  577. contractNo=contract.contract_no or link.contract_no,
  578. stopId=stop_id_norm,
  579. )
  580. )
  581. _clear_legacy_side_contracts(
  582. db,
  583. tenant_no=tenant_no,
  584. plan_id=plan.id,
  585. legacy_sides=legacy_sides,
  586. user_id=user_id,
  587. )
  588. _write_stop_contract_rows(
  589. db,
  590. tenant_no=tenant_no,
  591. plan_id=plan.id,
  592. stop_id=stop_id_norm,
  593. side=side,
  594. contracts=resolved,
  595. user_id=user_id,
  596. dept_id=dept_id,
  597. )
  598. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  599. db, tenant_no, [plan.id]
  600. ).get(plan.id, ([], []))
  601. products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
  602. load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
  603. plan.id, (None, [])
  604. )
  605. return build_plan_contract_gap_read(
  606. plan,
  607. supplier_contracts=supplier_contracts,
  608. demander_contracts=demander_contracts,
  609. products=products,
  610. load_read=load_read,
  611. delivery_reads=delivery_reads,
  612. )
  613. def update_plan_side_contracts(
  614. db: Session,
  615. *,
  616. tenant_no: str,
  617. plan: PlPlan,
  618. side: str,
  619. contracts: list[PlanContractLink],
  620. user_id: Optional[str],
  621. dept_id: Optional[str],
  622. ) -> PlanContractGapRead:
  623. """单侧保存计划合同关联(旧计划级接口,兼容保留)。"""
  624. if plan.follow_status == "cancelled":
  625. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  626. if side not in (PLAN_CONTRACT_SIDE_SUPPLIER, PLAN_CONTRACT_SIDE_DEMANDER):
  627. raise HTTPException(status_code=422, detail="side 必须为 supplier 或 demander")
  628. current_s, current_d = load_plan_contracts_grouped(db, tenant_no, [plan.id]).get(
  629. plan.id, ([], [])
  630. )
  631. links = _normalize_contract_links(contracts)
  632. expected_customer_id = (
  633. plan.supplier_id if side == PLAN_CONTRACT_SIDE_SUPPLIER else plan.customer_id
  634. )
  635. side_label = "供方合同" if side == PLAN_CONTRACT_SIDE_SUPPLIER else "需方合同"
  636. resolved: list[PlanContractLink] = []
  637. for link in links:
  638. contract = assert_contract_selectable_for_plan(
  639. db,
  640. link.contract_id,
  641. tenant_no,
  642. expected_customer_id=expected_customer_id,
  643. side_label=side_label,
  644. )
  645. resolved.append(
  646. PlanContractLink(
  647. contractId=contract.id,
  648. contractNo=contract.contract_no or link.contract_no,
  649. )
  650. )
  651. supplier_links = resolved if side == PLAN_CONTRACT_SIDE_SUPPLIER else current_s
  652. demander_links = resolved if side == PLAN_CONTRACT_SIDE_DEMANDER else current_d
  653. replace_plan_contracts(
  654. db,
  655. tenant_no=tenant_no,
  656. plan_id=plan.id,
  657. supplier_contracts=supplier_links,
  658. demander_contracts=demander_links,
  659. user_id=user_id,
  660. dept_id=dept_id,
  661. )
  662. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  663. db, tenant_no, [plan.id]
  664. ).get(plan.id, ([], []))
  665. products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
  666. load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
  667. plan.id, (None, [])
  668. )
  669. return build_plan_contract_gap_read(
  670. plan,
  671. supplier_contracts=supplier_contracts,
  672. demander_contracts=demander_contracts,
  673. products=products,
  674. load_read=load_read,
  675. delivery_reads=delivery_reads,
  676. )