pl_plan_contract_gap.py 22 KB

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