pl_plan_contract_gap.py 23 KB

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