pl_plan_contract_gap.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  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. sort_field: Optional[str] = None,
  299. sort_order: Optional[str] = None,
  300. ) -> tuple[int, list[PlanContractGapRead]]:
  301. query = db.query(PlPlan).filter(
  302. PlPlan.tenant_no == tenant_no,
  303. PlPlan.del_flag == 0,
  304. PlPlan.follow_status != "cancelled",
  305. plan_has_contract_gap_exists(tenant_no),
  306. )
  307. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  308. gap_vals = [v.lower() for v in _parse_csv(gap_type)]
  309. if len(gap_vals) == 1:
  310. gap_type_norm = gap_vals[0]
  311. if gap_type_norm == "supplier":
  312. query = query.filter(_supplier_gap_expr(tenant_no))
  313. elif gap_type_norm == "demander":
  314. query = query.filter(_demander_gap_expr(tenant_no))
  315. elif gap_type_norm == "both":
  316. query = query.filter(
  317. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  318. )
  319. elif len(gap_vals) > 1:
  320. clauses = []
  321. for gap_type_norm in gap_vals:
  322. if gap_type_norm == "supplier":
  323. clauses.append(_supplier_gap_expr(tenant_no))
  324. elif gap_type_norm == "demander":
  325. clauses.append(_demander_gap_expr(tenant_no))
  326. elif gap_type_norm == "both":
  327. clauses.append(
  328. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  329. )
  330. if clauses:
  331. query = query.filter(or_(*clauses))
  332. follow_statuses = _parse_csv(follow_status)
  333. if len(follow_statuses) == 1:
  334. query = query.filter(PlPlan.follow_status == follow_statuses[0])
  335. elif len(follow_statuses) > 1:
  336. query = query.filter(PlPlan.follow_status.in_(follow_statuses))
  337. if customer_keyword:
  338. kw = customer_keyword.strip()
  339. if kw:
  340. kw_lower = kw.lower()
  341. load_match = exists(
  342. select(PlPlanLoad.id).where(
  343. PlPlanLoad.plan_id == PlPlan.id,
  344. PlPlanLoad.tenant_no == tenant_no,
  345. PlPlanLoad.del_flag == 0,
  346. func.lower(PlPlanLoad.customer_name).contains(kw_lower),
  347. )
  348. )
  349. delivery_match = exists(
  350. select(PlPlanDelivery.id).where(
  351. PlPlanDelivery.plan_id == PlPlan.id,
  352. PlPlanDelivery.tenant_no == tenant_no,
  353. PlPlanDelivery.del_flag == 0,
  354. func.lower(PlPlanDelivery.customer_name).contains(kw_lower),
  355. )
  356. )
  357. query = query.filter(
  358. or_(
  359. func.lower(PlPlan.supplier_name).contains(kw_lower),
  360. func.lower(PlPlan.customer_name).contains(kw_lower),
  361. load_match,
  362. delivery_match,
  363. )
  364. )
  365. column_filters = build_plan_column_filters(
  366. db=db,
  367. tenant_no=tenant_no,
  368. plan_no=plan_no,
  369. plan_type=plan_type,
  370. plan_type_in=plan_type_in,
  371. plan_date_from=plan_date_from,
  372. plan_date_to=plan_date_to,
  373. )
  374. if column_filters:
  375. query = query.filter(*column_filters)
  376. if q:
  377. q_kw = q.strip()
  378. if q_kw:
  379. q_lower = q_kw.lower()
  380. load_match = exists(
  381. select(PlPlanLoad.id).where(
  382. PlPlanLoad.plan_id == PlPlan.id,
  383. PlPlanLoad.tenant_no == tenant_no,
  384. PlPlanLoad.del_flag == 0,
  385. func.lower(PlPlanLoad.customer_name).contains(q_lower),
  386. )
  387. )
  388. delivery_match = exists(
  389. select(PlPlanDelivery.id).where(
  390. PlPlanDelivery.plan_id == PlPlan.id,
  391. PlPlanDelivery.tenant_no == tenant_no,
  392. PlPlanDelivery.del_flag == 0,
  393. func.lower(PlPlanDelivery.customer_name).contains(q_lower),
  394. )
  395. )
  396. query = query.filter(
  397. or_(
  398. PlPlan.plan_no.contains(q_kw),
  399. func.lower(PlPlan.supplier_name).contains(q_lower),
  400. func.lower(PlPlan.customer_name).contains(q_lower),
  401. load_match,
  402. delivery_match,
  403. )
  404. )
  405. if ids:
  406. query = query.filter(PlPlan.id.in_(ids))
  407. from app.services.pl.list_sort import order_plan
  408. query = query.order_by(
  409. order_plan(sort_field, sort_order, default=PlPlan.plan_date.desc()),
  410. PlPlan.create_time.desc(),
  411. )
  412. total = query.count()
  413. page = max(1, page)
  414. cap = int(max_page_size) if max_page_size else 500
  415. page_size = min(max(1, page_size), cap)
  416. offset = (page - 1) * page_size
  417. plans = query.offset(offset).limit(page_size).all()
  418. plan_ids = [plan.id for plan in plans]
  419. contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
  420. products_by_plan = _load_products_by_plan_ids(db, plan_ids, tenant_no=tenant_no)
  421. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  422. items: list[PlanContractGapRead] = []
  423. for plan in plans:
  424. supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
  425. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  426. items.append(
  427. build_plan_contract_gap_read(
  428. plan,
  429. supplier_contracts=supplier_contracts,
  430. demander_contracts=demander_contracts,
  431. products=products_by_plan.get(plan.id, []),
  432. load_read=load_read,
  433. delivery_reads=delivery_reads,
  434. )
  435. )
  436. return total, items
  437. def _write_stop_contract_rows(
  438. db: Session,
  439. *,
  440. tenant_no: str,
  441. plan_id: str,
  442. stop_id: str,
  443. side: str,
  444. contracts: list[PlanContractLink],
  445. user_id: Optional[str],
  446. dept_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. PlPlanContract.stop_id == stop_id,
  454. ).update(
  455. {"del_flag": 1, "update_by": user_id, "update_time": now},
  456. synchronize_session=False,
  457. )
  458. order_num = 0
  459. for link in contracts:
  460. order_num += 1
  461. db.add(
  462. PlPlanContract(
  463. id=generate_uuid(),
  464. plan_id=plan_id,
  465. side=side,
  466. stop_id=stop_id,
  467. contract_id=link.contract_id,
  468. contract_no=link.contract_no,
  469. tenant_no=tenant_no,
  470. order_num=order_num,
  471. create_by=user_id,
  472. create_dept_id=dept_id or "",
  473. create_time=now,
  474. update_by=user_id,
  475. update_time=now,
  476. del_flag=0,
  477. )
  478. )
  479. def _clear_legacy_side_contracts(
  480. db: Session,
  481. *,
  482. tenant_no: str,
  483. plan_id: str,
  484. legacy_sides: tuple[str, ...],
  485. user_id: Optional[str],
  486. ) -> None:
  487. now = datetime.now()
  488. db.query(PlPlanContract).filter(
  489. PlPlanContract.plan_id == plan_id,
  490. PlPlanContract.tenant_no == tenant_no,
  491. PlPlanContract.del_flag == 0,
  492. or_(PlPlanContract.stop_id.is_(None), PlPlanContract.stop_id == ""),
  493. PlPlanContract.side.in_(legacy_sides),
  494. ).update(
  495. {"del_flag": 1, "update_by": user_id, "update_time": now},
  496. synchronize_session=False,
  497. )
  498. def update_plan_stop_contracts(
  499. db: Session,
  500. *,
  501. tenant_no: str,
  502. plan: PlPlan,
  503. stop_type: str,
  504. stop_id: str,
  505. contracts: list[PlanContractLink],
  506. user_id: Optional[str],
  507. dept_id: Optional[str],
  508. ) -> PlanContractGapRead:
  509. """保存某装货/送货段的合同关联。"""
  510. if plan.follow_status == "cancelled":
  511. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  512. stop_type_norm = (stop_type or "").strip().lower()
  513. stop_id_norm = (stop_id or "").strip()
  514. if stop_type_norm not in ("load", "delivery") or not stop_id_norm:
  515. raise HTTPException(status_code=422, detail="stopType 必须为 load 或 delivery,且 stopId 不能为空")
  516. if stop_type_norm == "load":
  517. stop = (
  518. db.query(PlPlanLoad)
  519. .filter(
  520. PlPlanLoad.id == stop_id_norm,
  521. PlPlanLoad.plan_id == plan.id,
  522. PlPlanLoad.tenant_no == tenant_no,
  523. PlPlanLoad.del_flag == 0,
  524. )
  525. .first()
  526. )
  527. side = PLAN_CONTRACT_SIDE_LOAD
  528. legacy_sides = (PLAN_CONTRACT_SIDE_SUPPLIER,)
  529. side_label = "装货段合同"
  530. else:
  531. stop = (
  532. db.query(PlPlanDelivery)
  533. .filter(
  534. PlPlanDelivery.id == stop_id_norm,
  535. PlPlanDelivery.plan_id == plan.id,
  536. PlPlanDelivery.tenant_no == tenant_no,
  537. PlPlanDelivery.del_flag == 0,
  538. )
  539. .first()
  540. )
  541. side = PLAN_CONTRACT_SIDE_DELIVERY
  542. legacy_sides = (PLAN_CONTRACT_SIDE_DEMANDER,)
  543. side_label = "送货段合同"
  544. if not stop:
  545. raise HTTPException(status_code=404, detail="段次不存在")
  546. if is_internal_customer_name(stop.customer_name):
  547. raise HTTPException(status_code=400, detail="内置客户无需补充合同")
  548. links = _normalize_contract_links(contracts)
  549. links = assert_at_most_one_contract(links, side_label)
  550. expected_customer_id = stop.customer_id
  551. resolved: list[PlanContractLink] = []
  552. for link in links:
  553. contract = assert_contract_selectable_for_plan(
  554. db,
  555. link.contract_id,
  556. tenant_no,
  557. expected_customer_id=expected_customer_id,
  558. side_label=side_label,
  559. )
  560. resolved.append(
  561. PlanContractLink(
  562. contractId=contract.id,
  563. contractNo=contract.contract_no or link.contract_no,
  564. stopId=stop_id_norm,
  565. )
  566. )
  567. _clear_legacy_side_contracts(
  568. db,
  569. tenant_no=tenant_no,
  570. plan_id=plan.id,
  571. legacy_sides=legacy_sides,
  572. user_id=user_id,
  573. )
  574. _write_stop_contract_rows(
  575. db,
  576. tenant_no=tenant_no,
  577. plan_id=plan.id,
  578. stop_id=stop_id_norm,
  579. side=side,
  580. contracts=resolved,
  581. user_id=user_id,
  582. dept_id=dept_id,
  583. )
  584. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  585. db, tenant_no, [plan.id]
  586. ).get(plan.id, ([], []))
  587. products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
  588. load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
  589. plan.id, (None, [])
  590. )
  591. return build_plan_contract_gap_read(
  592. plan,
  593. supplier_contracts=supplier_contracts,
  594. demander_contracts=demander_contracts,
  595. products=products,
  596. load_read=load_read,
  597. delivery_reads=delivery_reads,
  598. )
  599. def update_plan_side_contracts(
  600. db: Session,
  601. *,
  602. tenant_no: str,
  603. plan: PlPlan,
  604. side: str,
  605. contracts: list[PlanContractLink],
  606. user_id: Optional[str],
  607. dept_id: Optional[str],
  608. ) -> PlanContractGapRead:
  609. """单侧保存计划合同关联(旧计划级接口,兼容保留)。"""
  610. if plan.follow_status == "cancelled":
  611. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  612. if side not in (PLAN_CONTRACT_SIDE_SUPPLIER, PLAN_CONTRACT_SIDE_DEMANDER):
  613. raise HTTPException(status_code=422, detail="side 必须为 supplier 或 demander")
  614. current_s, current_d = load_plan_contracts_grouped(db, tenant_no, [plan.id]).get(
  615. plan.id, ([], [])
  616. )
  617. links = _normalize_contract_links(contracts)
  618. expected_customer_id = (
  619. plan.supplier_id if side == PLAN_CONTRACT_SIDE_SUPPLIER else plan.customer_id
  620. )
  621. side_label = "供方合同" if side == PLAN_CONTRACT_SIDE_SUPPLIER else "需方合同"
  622. resolved: list[PlanContractLink] = []
  623. for link in links:
  624. contract = assert_contract_selectable_for_plan(
  625. db,
  626. link.contract_id,
  627. tenant_no,
  628. expected_customer_id=expected_customer_id,
  629. side_label=side_label,
  630. )
  631. resolved.append(
  632. PlanContractLink(
  633. contractId=contract.id,
  634. contractNo=contract.contract_no or link.contract_no,
  635. )
  636. )
  637. supplier_links = resolved if side == PLAN_CONTRACT_SIDE_SUPPLIER else current_s
  638. demander_links = resolved if side == PLAN_CONTRACT_SIDE_DEMANDER else current_d
  639. replace_plan_contracts(
  640. db,
  641. tenant_no=tenant_no,
  642. plan_id=plan.id,
  643. supplier_contracts=supplier_links,
  644. demander_contracts=demander_links,
  645. user_id=user_id,
  646. dept_id=dept_id,
  647. )
  648. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  649. db, tenant_no, [plan.id]
  650. ).get(plan.id, ([], []))
  651. products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
  652. load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
  653. plan.id, (None, [])
  654. )
  655. return build_plan_contract_gap_read(
  656. plan,
  657. supplier_contracts=supplier_contracts,
  658. demander_contracts=demander_contracts,
  659. products=products,
  660. load_read=load_read,
  661. delivery_reads=delivery_reads,
  662. )