pl_plan_contract_gap.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  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 apply_contract_gap_type_filter(query, tenant_no: str, gap_type: Optional[str]):
  212. """按缺口类型收窄计划查询(与 list_plan_contract_gaps 一致)。"""
  213. gap_vals = [v.lower() for v in _parse_csv(gap_type)]
  214. if len(gap_vals) == 1:
  215. gap_type_norm = gap_vals[0]
  216. if gap_type_norm == "supplier":
  217. return query.filter(_supplier_gap_expr(tenant_no))
  218. if gap_type_norm == "demander":
  219. return query.filter(_demander_gap_expr(tenant_no))
  220. if gap_type_norm == "both":
  221. return query.filter(
  222. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  223. )
  224. return query
  225. if len(gap_vals) > 1:
  226. clauses = []
  227. for gap_type_norm in gap_vals:
  228. if gap_type_norm == "supplier":
  229. clauses.append(_supplier_gap_expr(tenant_no))
  230. elif gap_type_norm == "demander":
  231. clauses.append(_demander_gap_expr(tenant_no))
  232. elif gap_type_norm == "both":
  233. clauses.append(
  234. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  235. )
  236. if clauses:
  237. return query.filter(or_(*clauses))
  238. return query
  239. def build_plan_contract_gap_read(
  240. plan: PlPlan,
  241. *,
  242. supplier_contracts: list[PlanContractLink],
  243. demander_contracts: list[PlanContractLink],
  244. products: Optional[list[PlanProductRead]] = None,
  245. load_read: Optional[PlanLoadRead] = None,
  246. delivery_reads: Optional[list[PlanDeliveryRead]] = None,
  247. ) -> PlanContractGapRead:
  248. deliveries = list(delivery_reads or [])
  249. gap_sides = compute_plan_gap_sides_from_stops(
  250. load_read,
  251. deliveries,
  252. supplier_name=plan.supplier_name,
  253. customer_name=plan.customer_name,
  254. supplier_contract_count=len(supplier_contracts),
  255. demander_contract_count=len(demander_contracts),
  256. )
  257. return PlanContractGapRead(
  258. id=plan.id,
  259. planNo=plan.plan_no,
  260. planDate=plan.plan_date,
  261. planType=plan.plan_type,
  262. followStatus=plan.follow_status,
  263. supplierId=plan.supplier_id,
  264. supplierName=plan.supplier_name,
  265. customerId=plan.customer_id,
  266. customerName=plan.customer_name,
  267. gapSides=gap_sides,
  268. supplierContractExempt=side_is_exempt(plan.supplier_name),
  269. demanderContractExempt=side_is_exempt(plan.customer_name),
  270. supplierContracts=supplier_contracts,
  271. demanderContracts=demander_contracts,
  272. products=list(products or []),
  273. load=load_read,
  274. deliveries=deliveries,
  275. deliveryTimes=collect_unique_delivery_date_strings(deliveries),
  276. )
  277. def _load_products_by_plan_ids(
  278. db: Session,
  279. plan_ids: list[str],
  280. *,
  281. tenant_no: str,
  282. ) -> dict[str, list[PlanProductRead]]:
  283. if not plan_ids:
  284. return {}
  285. rows = (
  286. db.query(PlPlanProduct)
  287. .filter(
  288. PlPlanProduct.plan_id.in_(plan_ids),
  289. PlPlanProduct.del_flag == 0,
  290. )
  291. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  292. .all()
  293. )
  294. product_ids = {(row.product_id or "").strip() for row in rows if (row.product_id or "").strip()}
  295. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  296. db, tenant_no=tenant_no, product_ids=product_ids
  297. )
  298. out: dict[str, list[PlanProductRead]] = {pid: [] for pid in plan_ids}
  299. for row in rows:
  300. item = PlanProductRead.model_validate(row)
  301. display_name, _alias = resolve_product_display_names(
  302. saved_product_name=row.product_name,
  303. product_id=row.product_id,
  304. standard_by_id=standard_by_id,
  305. alias_options_by_id=alias_options_by_id,
  306. )
  307. item.product_name = display_name or (row.product_name or "")
  308. out.setdefault(row.plan_id, []).append(item)
  309. return out
  310. def _parse_csv(value: Optional[str]) -> List[str]:
  311. if not value:
  312. return []
  313. return [item.strip() for item in str(value).split(",") if item.strip()]
  314. def apply_contract_gap_keyword_filter(
  315. query,
  316. tenant_no: str,
  317. keyword: Optional[str],
  318. *,
  319. include_plan_no: bool = True,
  320. ):
  321. """合同完善列表与候选值共用计划客户及装/送货客户搜索。"""
  322. kw = (keyword or "").strip()
  323. if not kw:
  324. return query
  325. kw_lower = kw.lower()
  326. customer_matches = [
  327. func.lower(PlPlan.supplier_name).contains(kw_lower),
  328. func.lower(PlPlan.customer_name).contains(kw_lower),
  329. ]
  330. for stop_model in (PlPlanLoad, PlPlanDelivery):
  331. customer_matches.append(
  332. exists(
  333. select(stop_model.id).where(
  334. stop_model.plan_id == PlPlan.id,
  335. stop_model.tenant_no == tenant_no,
  336. stop_model.del_flag == 0,
  337. func.lower(stop_model.customer_name).contains(kw_lower),
  338. )
  339. )
  340. )
  341. if include_plan_no:
  342. customer_matches.insert(0, PlPlan.plan_no.contains(kw))
  343. return query.filter(or_(*customer_matches))
  344. def list_plan_contract_gaps(
  345. db: Session,
  346. *,
  347. tenant_no: str,
  348. user: "DbUserContext",
  349. page: int = 1,
  350. page_size: int = 20,
  351. plan_no: Optional[str] = None,
  352. gap_type: Optional[str] = None,
  353. customer_keyword: Optional[str] = None,
  354. plan_date_from: Optional[date] = None,
  355. plan_date_to: Optional[date] = None,
  356. delivery_time_from: Optional[date] = None,
  357. delivery_time_to: Optional[date] = None,
  358. plan_type: Optional[str] = None,
  359. plan_type_in: Optional[str] = None,
  360. follow_status: Optional[str] = None,
  361. q: Optional[str] = None,
  362. ids: Optional[list] = None,
  363. max_page_size: Optional[int] = None,
  364. sort_field: Optional[str] = None,
  365. sort_order: Optional[str] = None,
  366. column_filters: Optional[list] = None,
  367. ) -> tuple[int, list[PlanContractGapRead]]:
  368. query = db.query(PlPlan).filter(
  369. PlPlan.tenant_no == tenant_no,
  370. PlPlan.del_flag == 0,
  371. PlPlan.follow_status != "cancelled",
  372. plan_has_contract_gap_exists(tenant_no),
  373. )
  374. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  375. gap_vals = [v.lower() for v in _parse_csv(gap_type)]
  376. if len(gap_vals) == 1:
  377. gap_type_norm = gap_vals[0]
  378. if gap_type_norm == "supplier":
  379. query = query.filter(_supplier_gap_expr(tenant_no))
  380. elif gap_type_norm == "demander":
  381. query = query.filter(_demander_gap_expr(tenant_no))
  382. elif gap_type_norm == "both":
  383. query = query.filter(
  384. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  385. )
  386. elif len(gap_vals) > 1:
  387. clauses = []
  388. for gap_type_norm in gap_vals:
  389. if gap_type_norm == "supplier":
  390. clauses.append(_supplier_gap_expr(tenant_no))
  391. elif gap_type_norm == "demander":
  392. clauses.append(_demander_gap_expr(tenant_no))
  393. elif gap_type_norm == "both":
  394. clauses.append(
  395. and_(_supplier_gap_expr(tenant_no), _demander_gap_expr(tenant_no))
  396. )
  397. if clauses:
  398. query = query.filter(or_(*clauses))
  399. follow_statuses = _parse_csv(follow_status)
  400. if len(follow_statuses) == 1:
  401. query = query.filter(PlPlan.follow_status == follow_statuses[0])
  402. elif len(follow_statuses) > 1:
  403. query = query.filter(PlPlan.follow_status.in_(follow_statuses))
  404. query = apply_contract_gap_keyword_filter(
  405. query, tenant_no, customer_keyword, include_plan_no=False
  406. )
  407. if column_filters is not None:
  408. if column_filters:
  409. query = query.filter(*column_filters)
  410. else:
  411. legacy_filters = build_plan_column_filters(
  412. db=db,
  413. tenant_no=tenant_no,
  414. plan_no=plan_no,
  415. plan_type=plan_type,
  416. plan_type_in=plan_type_in,
  417. plan_date_from=plan_date_from,
  418. plan_date_to=plan_date_to,
  419. )
  420. if legacy_filters:
  421. query = query.filter(*legacy_filters)
  422. dt_from = _parse_date(delivery_time_from)
  423. dt_to = _parse_date(delivery_time_to)
  424. if dt_from is not None or dt_to is not None:
  425. query = query.filter(
  426. plan_delivery_time_exists(db, tenant_no, d_from=dt_from, d_to=dt_to)
  427. )
  428. query = apply_contract_gap_keyword_filter(query, tenant_no, q)
  429. if ids:
  430. query = query.filter(PlPlan.id.in_(ids))
  431. from app.services.pl.list_sort import order_plan
  432. query = query.order_by(
  433. order_plan(sort_field, sort_order, default=PlPlan.plan_date.desc()),
  434. PlPlan.create_time.desc(),
  435. )
  436. total = query.count()
  437. page = max(1, page)
  438. cap = int(max_page_size) if max_page_size else 500
  439. page_size = min(max(1, page_size), cap)
  440. offset = (page - 1) * page_size
  441. plans = query.offset(offset).limit(page_size).all()
  442. plan_ids = [plan.id for plan in plans]
  443. contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
  444. products_by_plan = _load_products_by_plan_ids(db, plan_ids, tenant_no=tenant_no)
  445. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  446. items: list[PlanContractGapRead] = []
  447. for plan in plans:
  448. supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
  449. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  450. items.append(
  451. build_plan_contract_gap_read(
  452. plan,
  453. supplier_contracts=supplier_contracts,
  454. demander_contracts=demander_contracts,
  455. products=products_by_plan.get(plan.id, []),
  456. load_read=load_read,
  457. delivery_reads=delivery_reads,
  458. )
  459. )
  460. return total, items
  461. def _write_stop_contract_rows(
  462. db: Session,
  463. *,
  464. tenant_no: str,
  465. plan_id: str,
  466. stop_id: str,
  467. side: str,
  468. contracts: list[PlanContractLink],
  469. user_id: Optional[str],
  470. dept_id: Optional[str],
  471. ) -> None:
  472. now = datetime.now()
  473. db.query(PlPlanContract).filter(
  474. PlPlanContract.plan_id == plan_id,
  475. PlPlanContract.tenant_no == tenant_no,
  476. PlPlanContract.del_flag == 0,
  477. PlPlanContract.stop_id == stop_id,
  478. ).update(
  479. {"del_flag": 1, "update_by": user_id, "update_time": now},
  480. synchronize_session=False,
  481. )
  482. order_num = 0
  483. for link in contracts:
  484. order_num += 1
  485. db.add(
  486. PlPlanContract(
  487. id=generate_uuid(),
  488. plan_id=plan_id,
  489. side=side,
  490. stop_id=stop_id,
  491. contract_id=link.contract_id,
  492. contract_no=link.contract_no,
  493. tenant_no=tenant_no,
  494. order_num=order_num,
  495. create_by=user_id,
  496. create_dept_id=dept_id or "",
  497. create_time=now,
  498. update_by=user_id,
  499. update_time=now,
  500. del_flag=0,
  501. )
  502. )
  503. def _clear_legacy_side_contracts(
  504. db: Session,
  505. *,
  506. tenant_no: str,
  507. plan_id: str,
  508. legacy_sides: tuple[str, ...],
  509. user_id: Optional[str],
  510. ) -> None:
  511. now = datetime.now()
  512. db.query(PlPlanContract).filter(
  513. PlPlanContract.plan_id == plan_id,
  514. PlPlanContract.tenant_no == tenant_no,
  515. PlPlanContract.del_flag == 0,
  516. or_(PlPlanContract.stop_id.is_(None), PlPlanContract.stop_id == ""),
  517. PlPlanContract.side.in_(legacy_sides),
  518. ).update(
  519. {"del_flag": 1, "update_by": user_id, "update_time": now},
  520. synchronize_session=False,
  521. )
  522. def update_plan_stop_contracts(
  523. db: Session,
  524. *,
  525. tenant_no: str,
  526. plan: PlPlan,
  527. stop_type: str,
  528. stop_id: str,
  529. contracts: list[PlanContractLink],
  530. user_id: Optional[str],
  531. dept_id: Optional[str],
  532. ) -> PlanContractGapRead:
  533. """保存某装货/送货段的合同关联。"""
  534. if plan.follow_status == "cancelled":
  535. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  536. stop_type_norm = (stop_type or "").strip().lower()
  537. stop_id_norm = (stop_id or "").strip()
  538. if stop_type_norm not in ("load", "delivery") or not stop_id_norm:
  539. raise HTTPException(status_code=422, detail="stopType 必须为 load 或 delivery,且 stopId 不能为空")
  540. if stop_type_norm == "load":
  541. stop = (
  542. db.query(PlPlanLoad)
  543. .filter(
  544. PlPlanLoad.id == stop_id_norm,
  545. PlPlanLoad.plan_id == plan.id,
  546. PlPlanLoad.tenant_no == tenant_no,
  547. PlPlanLoad.del_flag == 0,
  548. )
  549. .first()
  550. )
  551. side = PLAN_CONTRACT_SIDE_LOAD
  552. legacy_sides = (PLAN_CONTRACT_SIDE_SUPPLIER,)
  553. side_label = "装货段合同"
  554. else:
  555. stop = (
  556. db.query(PlPlanDelivery)
  557. .filter(
  558. PlPlanDelivery.id == stop_id_norm,
  559. PlPlanDelivery.plan_id == plan.id,
  560. PlPlanDelivery.tenant_no == tenant_no,
  561. PlPlanDelivery.del_flag == 0,
  562. )
  563. .first()
  564. )
  565. side = PLAN_CONTRACT_SIDE_DELIVERY
  566. legacy_sides = (PLAN_CONTRACT_SIDE_DEMANDER,)
  567. side_label = "送货段合同"
  568. if not stop:
  569. raise HTTPException(status_code=404, detail="段次不存在")
  570. if is_internal_customer_name(stop.customer_name):
  571. raise HTTPException(status_code=400, detail="内置客户无需补充合同")
  572. links = _normalize_contract_links(contracts)
  573. links = assert_at_most_one_contract(links, side_label)
  574. expected_customer_id = stop.customer_id
  575. resolved: list[PlanContractLink] = []
  576. for link in links:
  577. contract = assert_contract_selectable_for_plan(
  578. db,
  579. link.contract_id,
  580. tenant_no,
  581. expected_customer_id=expected_customer_id,
  582. side_label=side_label,
  583. )
  584. resolved.append(
  585. PlanContractLink(
  586. contractId=contract.id,
  587. contractNo=contract.contract_no or link.contract_no,
  588. stopId=stop_id_norm,
  589. )
  590. )
  591. _clear_legacy_side_contracts(
  592. db,
  593. tenant_no=tenant_no,
  594. plan_id=plan.id,
  595. legacy_sides=legacy_sides,
  596. user_id=user_id,
  597. )
  598. _write_stop_contract_rows(
  599. db,
  600. tenant_no=tenant_no,
  601. plan_id=plan.id,
  602. stop_id=stop_id_norm,
  603. side=side,
  604. contracts=resolved,
  605. user_id=user_id,
  606. dept_id=dept_id,
  607. )
  608. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  609. db, tenant_no, [plan.id]
  610. ).get(plan.id, ([], []))
  611. products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
  612. load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
  613. plan.id, (None, [])
  614. )
  615. return build_plan_contract_gap_read(
  616. plan,
  617. supplier_contracts=supplier_contracts,
  618. demander_contracts=demander_contracts,
  619. products=products,
  620. load_read=load_read,
  621. delivery_reads=delivery_reads,
  622. )
  623. def update_plan_side_contracts(
  624. db: Session,
  625. *,
  626. tenant_no: str,
  627. plan: PlPlan,
  628. side: str,
  629. contracts: list[PlanContractLink],
  630. user_id: Optional[str],
  631. dept_id: Optional[str],
  632. ) -> PlanContractGapRead:
  633. """单侧保存计划合同关联(旧计划级接口,兼容保留)。"""
  634. if plan.follow_status == "cancelled":
  635. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  636. if side not in (PLAN_CONTRACT_SIDE_SUPPLIER, PLAN_CONTRACT_SIDE_DEMANDER):
  637. raise HTTPException(status_code=422, detail="side 必须为 supplier 或 demander")
  638. current_s, current_d = load_plan_contracts_grouped(db, tenant_no, [plan.id]).get(
  639. plan.id, ([], [])
  640. )
  641. links = _normalize_contract_links(contracts)
  642. expected_customer_id = (
  643. plan.supplier_id if side == PLAN_CONTRACT_SIDE_SUPPLIER else plan.customer_id
  644. )
  645. side_label = "供方合同" if side == PLAN_CONTRACT_SIDE_SUPPLIER else "需方合同"
  646. resolved: list[PlanContractLink] = []
  647. for link in links:
  648. contract = assert_contract_selectable_for_plan(
  649. db,
  650. link.contract_id,
  651. tenant_no,
  652. expected_customer_id=expected_customer_id,
  653. side_label=side_label,
  654. )
  655. resolved.append(
  656. PlanContractLink(
  657. contractId=contract.id,
  658. contractNo=contract.contract_no or link.contract_no,
  659. )
  660. )
  661. supplier_links = resolved if side == PLAN_CONTRACT_SIDE_SUPPLIER else current_s
  662. demander_links = resolved if side == PLAN_CONTRACT_SIDE_DEMANDER else current_d
  663. replace_plan_contracts(
  664. db,
  665. tenant_no=tenant_no,
  666. plan_id=plan.id,
  667. supplier_contracts=supplier_links,
  668. demander_contracts=demander_links,
  669. user_id=user_id,
  670. dept_id=dept_id,
  671. )
  672. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  673. db, tenant_no, [plan.id]
  674. ).get(plan.id, ([], []))
  675. products = _load_products_by_plan_ids(db, [plan.id], tenant_no=tenant_no).get(plan.id, [])
  676. load_read, delivery_reads = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(
  677. plan.id, (None, [])
  678. )
  679. return build_plan_contract_gap_read(
  680. plan,
  681. supplier_contracts=supplier_contracts,
  682. demander_contracts=demander_contracts,
  683. products=products,
  684. load_read=load_read,
  685. delivery_reads=delivery_reads,
  686. )