plan.py 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849
  1. """运输计划管理 - 计划 API"""
  2. import re
  3. from datetime import date
  4. from typing import Optional
  5. from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
  6. from sqlalchemy import func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.config import settings
  9. from app.core.database import get_db
  10. from app.core.db_auth import DbUserContext, get_db_current_user
  11. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  12. from app.core.menu_guard import SUPER_ADMIN_ROLE, get_user_menu_codes
  13. from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
  14. from app.core.tenant_context import get_tenant_no
  15. from app.core.utils import generate_uuid
  16. from app.models.crm_models import CrmContract, CrmContractProduct, CrmProduct
  17. from app.models.db_models import DbInboundOutbound
  18. from app.models.pl_models import (
  19. PlCarrier,
  20. PlDriver,
  21. PlPlan,
  22. PlPlanProduct,
  23. PlPlanTransport,
  24. PlTransport,
  25. PlTransportDbLink,
  26. PlTransportSiteLink,
  27. PlVehicle,
  28. )
  29. from app.schemas.pl import PlanProductCreate
  30. from app.schemas.pl.plan import (
  31. CreateFromContractRequest,
  32. PlanArchiveBody,
  33. PlanArchiveBrandQtyItem,
  34. PlanContractGapListResponse,
  35. PlanContractGapRead,
  36. PlanCreate,
  37. PlanCreateFull,
  38. PlanDriverFeedbackUpdateBody,
  39. HazardCertReadyBody,
  40. PlanForceFollowStatusBody,
  41. PlanInboundSlipTemplateSave,
  42. PlanDeliverySlipTemplateSave,
  43. PlanSubpackSlipTemplateSave,
  44. PlanListQuery,
  45. PlanListResponse,
  46. PlanProductCertsUpdate,
  47. PlanProductRead,
  48. PlanRead,
  49. PlanSideContractsUpdateBody,
  50. PlanSiteFeedbackUpdateBody,
  51. PlanUpdate,
  52. PlanWithProducts,
  53. )
  54. from app.services.pl_plan_contract_gap import list_plan_contract_gaps, update_plan_side_contracts
  55. from app.services.pl_brand_archive import load_brand_qtys_by_plan_product_ids
  56. from app.services.pl_product_display import (
  57. load_crm_product_alias_maps,
  58. resolve_product_display_names,
  59. )
  60. from app.schemas.pl.transport import (
  61. PlanArrangeInfoRead,
  62. TransportInventoryLinkRead,
  63. TransportIoOrderBrief,
  64. TransportSiteLinkRead,
  65. )
  66. from app.services.pl_dibang_bridge_service import revoke_regeneratable_dibang_for_transport
  67. from app.services.pl_inventory_link_read import build_transport_inventory_link_read
  68. from app.services.pl_io_order_service import get_active_io_order_for_plan, void_io_orders_for_plan
  69. from app.services.pl_site_link_read import build_transport_site_link_read_from_row
  70. from app.services.pl_transport_site_service import revoke_site_links_for_transport
  71. from app.services.pl_inventory_service import apply_archive_inventory_for_plan
  72. from app.services.pl_plan_service import (
  73. apply_archive_freight,
  74. apply_archive_product_quantities,
  75. apply_plan_driver_feedback,
  76. apply_plan_site_feedback,
  77. apply_vehicle_prefill_to_plan,
  78. assert_contract_selectable_for_plan,
  79. build_plan_product,
  80. calc_plan_total_amount,
  81. clear_draft_transport_for_plan,
  82. clear_hazard_cert_ready,
  83. collect_hazard_product_ids,
  84. determine_plan_type,
  85. fetch_hazard_flags_map,
  86. load_plan_contracts_grouped,
  87. plan_has_hazard_product_exists,
  88. plan_has_incomplete_hazard_cert_exists,
  89. record_plan_change,
  90. replace_plan_contracts,
  91. resolve_hazard_flags_from_crm,
  92. set_hazard_cert_ready,
  93. sync_transport_prefill_for_plan,
  94. update_plan_product_certs,
  95. validate_create_full,
  96. validate_hazard_cert_ready_for_transport,
  97. validate_plan_party_contracts,
  98. )
  99. router = APIRouter(prefix="/plan", tags=["运输计划-计划管理"])
  100. _PLAN_NO_SEQ_WIDTH = 3
  101. def _resolve_list_product_name(
  102. product: PlPlanProduct,
  103. *,
  104. standard_by_id: dict[str, str],
  105. alias_options_by_id: dict[str, list[str]],
  106. ) -> str:
  107. """列表展示用:优先 CRM 标准名(详情仍返回库内 product_name,以便编辑反推别名)。"""
  108. display_name, _alias = resolve_product_display_names(
  109. saved_product_name=product.product_name,
  110. product_id=product.product_id,
  111. standard_by_id=standard_by_id,
  112. alias_options_by_id=alias_options_by_id,
  113. )
  114. return display_name or (product.product_name or "")
  115. def _resolve_create_date(value: Optional[date]) -> date:
  116. return value or date.today()
  117. def _generate_plan_no(db: Session) -> str:
  118. """生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)"""
  119. today = date.today()
  120. prefix = f"PO{today.strftime('%Y%m%d')}"
  121. # uk_plan_no 对全表生效(含软删),取号时不能过滤 del_flag
  122. rows = (
  123. db.query(PlPlan.plan_no)
  124. .filter(PlPlan.plan_no.like(f"{prefix}%"))
  125. .all()
  126. )
  127. max_seq = 0
  128. for (plan_no,) in rows:
  129. suffix = (plan_no or "")[len(prefix) :]
  130. if suffix.isdigit():
  131. max_seq = max(max_seq, int(suffix))
  132. seq = max_seq + 1
  133. return f"{prefix}{seq:0{_PLAN_NO_SEQ_WIDTH}d}"
  134. _SUBPACK_SLIP_NO_SEQ_WIDTH = 3
  135. _SUBPACK_SLIP_NO_CANONICAL_PATTERN = re.compile(r"^NO:SXFZ(\d{8})-(\d+)$")
  136. _SUBPACK_SLIP_NO_BARE_PATTERN = re.compile(r"^SXFZ(\d{8})-(\d+)$")
  137. _SUBPACK_SLIP_NO_LEGACY_PATTERNS = (
  138. re.compile(r"^NO:SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
  139. re.compile(r"^SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
  140. )
  141. def _format_subpack_slip_no(day: str, seq: int) -> str:
  142. return f"NO:SXFZ{day}-{int(seq):0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
  143. def _normalize_subpack_slip_no(slip_no: str) -> Optional[str]:
  144. """将历史分装单号统一为 NO:SXFZYYYYMMDD-###"""
  145. raw = (slip_no or "").strip()
  146. if not raw:
  147. return None
  148. matched = _SUBPACK_SLIP_NO_CANONICAL_PATTERN.match(raw)
  149. if matched:
  150. day, seq = matched.groups()
  151. return _format_subpack_slip_no(day, int(seq))
  152. matched = _SUBPACK_SLIP_NO_BARE_PATTERN.match(raw)
  153. if matched:
  154. day, seq = matched.groups()
  155. return _format_subpack_slip_no(day, int(seq))
  156. for pattern in _SUBPACK_SLIP_NO_LEGACY_PATTERNS:
  157. matched = pattern.match(raw)
  158. if matched:
  159. year, month, day, seq = matched.groups()
  160. return _format_subpack_slip_no(f"{year}{month}{day}", int(seq))
  161. return None
  162. def _subpack_slip_no_seq(slip_no: str, day_prefix: str) -> int:
  163. normalized = _normalize_subpack_slip_no(slip_no)
  164. if not normalized or not normalized.startswith(day_prefix):
  165. return 0
  166. suffix = normalized[len(day_prefix) :]
  167. return int(suffix) if suffix.isdigit() else 0
  168. def _ensure_subpack_slip_no(db: Session, tenant_no: str, plan: PlPlan) -> str:
  169. """分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)"""
  170. existing = (plan.subpack_slip_no or "").strip()
  171. if existing:
  172. normalized = _normalize_subpack_slip_no(existing)
  173. if normalized:
  174. if normalized != existing:
  175. plan.subpack_slip_no = normalized
  176. return normalized
  177. today = date.today()
  178. prefix = f"NO:SXFZ{today.strftime('%Y%m%d')}-"
  179. rows = (
  180. db.query(PlPlan.subpack_slip_no)
  181. .filter(
  182. PlPlan.tenant_no == tenant_no,
  183. PlPlan.subpack_slip_no.isnot(None),
  184. PlPlan.subpack_slip_no != "",
  185. )
  186. .all()
  187. )
  188. max_seq = 0
  189. for (slip_no,) in rows:
  190. max_seq = max(max_seq, _subpack_slip_no_seq(slip_no or "", prefix))
  191. next_no = f"{prefix}{max_seq + 1:0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
  192. plan.subpack_slip_no = next_no
  193. return next_no
  194. def _determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
  195. """根据供应商和目的地自动判断单据类型"""
  196. return determine_plan_type(supplier_name, destination_name)
  197. def _get_plan_type_label(plan_type: str) -> str:
  198. labels = {
  199. "direct": "直销单",
  200. "inbound": "入库单",
  201. "outbound": "出库单",
  202. }
  203. return labels.get(plan_type, plan_type)
  204. def _generate_notify_message(plan: PlPlan, products: list) -> str:
  205. """生成通知信息"""
  206. product_names = "、".join([p.product_name for p in products[:3]])
  207. if len(products) > 3:
  208. product_names += "等"
  209. return f"""计划号:{plan.plan_no}
  210. 企业名称:{plan.customer_name or ''}
  211. 提货单位:{plan.destination_name or ''}
  212. 到货时间:{plan.contract_end_date or ''}
  213. 药品名称:{product_names}
  214. 到货吨位:{sum(float(p.inbound_estimate_qty or p.outbound_estimate_qty or 0) for p in products)}吨
  215. 备注:"""
  216. # =============================================================================
  217. # 计划列表
  218. # =============================================================================
  219. def _plan_product_name_exists(db: Session, tenant_no: str, keyword: str):
  220. """子查询:计划产品名称模糊匹配。"""
  221. return (
  222. db.query(PlPlanProduct.id)
  223. .filter(
  224. PlPlanProduct.plan_id == PlPlan.id,
  225. PlPlanProduct.tenant_no == tenant_no,
  226. PlPlanProduct.del_flag == 0,
  227. func.lower(PlPlanProduct.product_name).contains(keyword.lower()),
  228. )
  229. .exists()
  230. )
  231. def _plan_plate_match(keyword: str):
  232. """车牌号 / 挂车号模糊匹配。"""
  233. kw = keyword.lower()
  234. return or_(
  235. func.lower(PlPlan.license_plate).contains(kw),
  236. func.lower(PlPlan.trailer_no).contains(kw),
  237. )
  238. @router.get("/", response_model=PlanListResponse)
  239. def list_plans(
  240. q: Optional[str] = Query(
  241. default=None,
  242. description="模糊搜索(计划编号/供方名称/需方名称/产品名称/车牌号)",
  243. ),
  244. plan_no: Optional[str] = Query(default=None, description="计划编号"),
  245. plan_type: Optional[str] = Query(default=None, alias="planType", description="单据类型"),
  246. follow_status: Optional[str] = Query(default=None, alias="followStatus", description="跟进状态"),
  247. transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
  248. archive_status: Optional[str] = Query(default=None, alias="archiveStatus", description="归档状态"),
  249. customer_name: Optional[str] = Query(default=None, alias="customerName", description="客户名称"),
  250. product_name: Optional[str] = Query(default=None, alias="productName", description="产品名称"),
  251. license_plate: Optional[str] = Query(default=None, alias="licensePlate", description="车牌号"),
  252. plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom", description="计划日期开始"),
  253. plan_date_to: Optional[date] = Query(default=None, alias="planDateTo", description="计划日期结束"),
  254. has_hazard_product: Optional[int] = Query(
  255. default=None,
  256. alias="hasHazardProduct",
  257. description="1=仅含易制毒/易制爆产品的计划",
  258. ),
  259. cert_upload_status: Optional[str] = Query(
  260. default=None,
  261. alias="certUploadStatus",
  262. description="incomplete=至少一张必需危化证书未上传",
  263. ),
  264. page: int = Query(default=1, ge=1),
  265. page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
  266. db: Session = Depends(get_db),
  267. user: DbUserContext = Depends(get_db_current_user),
  268. ) -> PlanListResponse:
  269. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  270. query = db.query(PlPlan).filter(
  271. PlPlan.tenant_no == tenant_no,
  272. PlPlan.del_flag == 0,
  273. )
  274. if q:
  275. q_kw = q.strip()
  276. if q_kw:
  277. q_lower = q_kw.lower()
  278. query = query.filter(
  279. or_(
  280. PlPlan.plan_no.contains(q_kw),
  281. func.lower(PlPlan.supplier_name).contains(q_lower),
  282. func.lower(PlPlan.customer_name).contains(q_lower),
  283. func.lower(PlPlan.destination_name).contains(q_lower),
  284. _plan_product_name_exists(db, tenant_no, q_kw),
  285. _plan_plate_match(q_kw),
  286. )
  287. )
  288. if plan_no:
  289. query = query.filter(PlPlan.plan_no.contains(plan_no))
  290. if plan_type:
  291. query = query.filter(PlPlan.plan_type == plan_type)
  292. if follow_status:
  293. query = query.filter(PlPlan.follow_status == follow_status)
  294. if transport_status:
  295. query = query.filter(PlPlan.transport_status == transport_status)
  296. if archive_status:
  297. query = query.filter(PlPlan.archive_status == archive_status)
  298. if customer_name:
  299. query = query.filter(func.lower(PlPlan.customer_name).contains(customer_name.lower()))
  300. if product_name:
  301. product_name_kw = product_name.strip()
  302. if product_name_kw:
  303. query = query.filter(_plan_product_name_exists(db, tenant_no, product_name_kw))
  304. if license_plate:
  305. plate_kw = license_plate.strip()
  306. if plate_kw:
  307. query = query.filter(_plan_plate_match(plate_kw))
  308. if plan_date_from:
  309. query = query.filter(PlPlan.plan_date >= plan_date_from)
  310. if plan_date_to:
  311. query = query.filter(PlPlan.plan_date <= plan_date_to)
  312. if has_hazard_product == 1:
  313. query = query.filter(plan_has_hazard_product_exists(tenant_no))
  314. if (cert_upload_status or "").strip().lower() == "incomplete":
  315. query = query.filter(plan_has_incomplete_hazard_cert_exists(tenant_no))
  316. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  317. # 统计总数
  318. total = query.count()
  319. # 分页
  320. offset = (page - 1) * page_size
  321. plans = query.order_by(PlPlan.create_time.desc()).offset(offset).limit(page_size).all()
  322. plan_ids = [plan.id for plan in plans]
  323. products_by_plan: dict[str, list[PlPlanProduct]] = {}
  324. all_products: list[PlPlanProduct] = []
  325. if plan_ids:
  326. all_products = (
  327. db.query(PlPlanProduct)
  328. .filter(
  329. PlPlanProduct.plan_id.in_(plan_ids),
  330. PlPlanProduct.del_flag == 0,
  331. )
  332. .order_by(PlPlanProduct.order_num)
  333. .all()
  334. )
  335. for product in all_products:
  336. products_by_plan.setdefault(product.plan_id, []).append(product)
  337. product_ids = [product.product_id for product in all_products if product.product_id]
  338. hazard_map = fetch_hazard_flags_map(
  339. db,
  340. tenant_no,
  341. product_ids,
  342. )
  343. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  344. db,
  345. tenant_no=tenant_no,
  346. product_ids=product_ids,
  347. )
  348. brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
  349. db,
  350. tenant_no=tenant_no,
  351. plan_product_ids=[p.id for p in all_products],
  352. )
  353. contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
  354. result_list: list[PlanWithProducts] = []
  355. for plan in plans:
  356. enriched_products: list[PlanProductRead] = []
  357. for product in products_by_plan.get(plan.id, []):
  358. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  359. db,
  360. tenant_no,
  361. product.product_id,
  362. hazard_map=hazard_map,
  363. )
  364. item = PlanProductRead.model_validate(product)
  365. raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
  366. brand_qtys = (
  367. [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
  368. if raw_brand_qtys
  369. else None
  370. )
  371. item = item.model_copy(
  372. update={
  373. "is_toxic": is_toxic,
  374. "is_explosive": is_explosive,
  375. "brand_qtys": brand_qtys,
  376. "product_name": _resolve_list_product_name(
  377. product,
  378. standard_by_id=standard_by_id,
  379. alias_options_by_id=alias_options_by_id,
  380. ),
  381. }
  382. )
  383. enriched_products.append(item)
  384. plan_item = PlanWithProducts.model_validate(plan)
  385. plan_item.products = enriched_products
  386. supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
  387. plan_item.supplier_contracts = supplier_contracts
  388. plan_item.demander_contracts = demander_contracts
  389. if not (plan_item.personnel_summary or "").strip():
  390. snapshot = plan_item.resource_snapshot
  391. if snapshot and snapshot.personnel_summary:
  392. plan_item.personnel_summary = snapshot.personnel_summary
  393. elif plan_item.driver_name:
  394. plan_item.personnel_summary = f"{plan_item.driver_name}(驾)"
  395. result_list.append(plan_item)
  396. return PlanListResponse(total=total, list=result_list)
  397. @router.get("/count-by-contract")
  398. def count_plans_by_contract(
  399. contract_ids: str = Query(..., alias="contractIds", description="合同ID,逗号分隔"),
  400. db: Session = Depends(get_db),
  401. user: DbUserContext = Depends(get_db_current_user),
  402. ) -> dict:
  403. from app.models.pl_models import PlPlanContract
  404. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  405. ids = [item.strip() for item in contract_ids.split(",") if item.strip()]
  406. if not ids:
  407. return {"counts": {}}
  408. # 旧单:主表 contract_id;新单:pl_plan_contract 分侧关联(按计划去重计数)
  409. legacy_rows = (
  410. db.query(PlPlan.contract_id, func.count(PlPlan.id))
  411. .filter(
  412. PlPlan.tenant_no == tenant_no,
  413. PlPlan.del_flag == 0,
  414. PlPlan.contract_id.in_(ids),
  415. )
  416. )
  417. legacy_rows = apply_pl_scope(legacy_rows, PlPlan, user, db, tenant_no).group_by(PlPlan.contract_id).all()
  418. counts: dict[str, int] = {contract_id: count for contract_id, count in legacy_rows}
  419. link_q = (
  420. db.query(PlPlanContract.contract_id, PlPlan.id)
  421. .join(PlPlan, PlPlan.id == PlPlanContract.plan_id)
  422. .filter(
  423. PlPlanContract.tenant_no == tenant_no,
  424. PlPlanContract.del_flag == 0,
  425. PlPlanContract.contract_id.in_(ids),
  426. PlPlan.tenant_no == tenant_no,
  427. PlPlan.del_flag == 0,
  428. )
  429. )
  430. link_q = apply_pl_scope(link_q, PlPlan, user, db, tenant_no)
  431. seen: set[tuple[str, str]] = set()
  432. for contract_id, plan_id in link_q.all():
  433. key = (contract_id, plan_id)
  434. if key in seen:
  435. continue
  436. seen.add(key)
  437. # 若旧主表已计过同一计划+合同,不再重复加(新单主表 contract_id 应为空)
  438. counts[contract_id] = counts.get(contract_id, 0) + 1
  439. return {"counts": counts}
  440. @router.get("/contract-gaps", response_model=PlanContractGapListResponse)
  441. def list_contract_gaps(
  442. page: int = Query(default=1, ge=1),
  443. page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
  444. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划编号"),
  445. gap_type: Optional[str] = Query(
  446. default=None,
  447. alias="gapType",
  448. description="缺口类型:supplier | demander | both",
  449. ),
  450. customer_keyword: Optional[str] = Query(
  451. default=None,
  452. alias="customerKeyword",
  453. description="供方或需方名称关键词",
  454. ),
  455. plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom"),
  456. plan_date_to: Optional[date] = Query(default=None, alias="planDateTo"),
  457. q: Optional[str] = Query(
  458. default=None,
  459. description="模糊搜索(计划编号/供方名称/需方名称)",
  460. ),
  461. db: Session = Depends(get_db),
  462. user: DbUserContext = Depends(get_db_current_user),
  463. ) -> PlanContractGapListResponse:
  464. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  465. total, items = list_plan_contract_gaps(
  466. db,
  467. tenant_no=tenant_no,
  468. user=user,
  469. page=page,
  470. page_size=page_size,
  471. plan_no=plan_no,
  472. gap_type=gap_type,
  473. customer_keyword=customer_keyword,
  474. plan_date_from=plan_date_from,
  475. plan_date_to=plan_date_to,
  476. q=q,
  477. )
  478. return PlanContractGapListResponse(total=total, list=items)
  479. @router.put("/{id}/contracts/{side}", response_model=PlanContractGapRead)
  480. def put_plan_side_contracts(
  481. id: str,
  482. side: str,
  483. payload: PlanSideContractsUpdateBody,
  484. db: Session = Depends(get_db),
  485. user: DbUserContext = Depends(get_db_current_user),
  486. ) -> PlanContractGapRead:
  487. """单侧保存计划合同关联(合同补全)。"""
  488. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  489. plan = _get_plan(db, tenant_no, id, user)
  490. result = update_plan_side_contracts(
  491. db,
  492. tenant_no=tenant_no,
  493. plan=plan,
  494. side=side,
  495. contracts=payload.contracts,
  496. user_id=user.id,
  497. dept_id=user.dept_id,
  498. )
  499. plan.update_by = user.id
  500. db.commit()
  501. return result
  502. # =============================================================================
  503. # 创建计划
  504. # =============================================================================
  505. @router.post("/", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  506. def create_plan(
  507. payload: PlanCreate,
  508. db: Session = Depends(get_db),
  509. user: DbUserContext = Depends(get_db_current_user),
  510. ) -> PlanRead:
  511. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  512. # 生成计划编号
  513. plan_no = _generate_plan_no(db)
  514. # 单据类型由前端显式选择;未选则存空,不再自动推断为直销单
  515. plan_type = (payload.plan_type or "").strip()
  516. plan = PlPlan(
  517. id=generate_uuid(),
  518. tenant_no=tenant_no,
  519. plan_no=plan_no,
  520. create_date=_resolve_create_date(payload.create_date),
  521. plan_date=payload.plan_date,
  522. belongs_company=payload.belongs_company,
  523. plan_type=plan_type,
  524. follow_status="pending_arrange",
  525. transport_status=payload.transport_status or "pending",
  526. archive_status=payload.archive_status or "unarchived",
  527. complete_status=payload.complete_status or "no",
  528. contract_id=payload.contract_id,
  529. contract_no=payload.contract_no,
  530. supplier_id=payload.supplier_id,
  531. supplier_name=payload.supplier_name,
  532. destination_id=payload.destination_id,
  533. destination_name=payload.destination_name,
  534. customer_id=payload.customer_id,
  535. customer_name=payload.customer_name,
  536. contact_name=payload.contact_name,
  537. contact_phone=payload.contact_phone,
  538. party_a=payload.party_a,
  539. party_b=payload.party_b,
  540. contract_start_date=payload.contract_start_date,
  541. contract_end_date=payload.contract_end_date,
  542. delivery_method=payload.delivery_method,
  543. settlement_method=payload.settlement_method,
  544. has_freight=payload.has_freight,
  545. pickup_address=payload.pickup_address,
  546. pickup_lng=payload.pickup_lng,
  547. pickup_lat=payload.pickup_lat,
  548. pickup_time=payload.pickup_time,
  549. pickup_contact=payload.pickup_contact,
  550. pickup_notes=payload.pickup_notes,
  551. destination_address=payload.destination_address,
  552. destination_lng=payload.destination_lng,
  553. destination_lat=payload.destination_lat,
  554. delivery_time=payload.delivery_time,
  555. delivery_contact=payload.delivery_contact,
  556. destination_notes=payload.destination_notes,
  557. follow_user_id=payload.follow_user_id or user.id,
  558. follow_user_name=payload.follow_user_name or user.nick_name,
  559. remark=payload.remark,
  560. create_by=user.id,
  561. create_dept_id=user.dept_id,
  562. )
  563. db.add(plan)
  564. # 添加产品明细
  565. if payload.products:
  566. for idx, prod in enumerate(payload.products):
  567. pp = build_plan_product(
  568. tenant_no=tenant_no,
  569. plan_id=plan.id,
  570. plan_type=plan_type,
  571. prod=prod,
  572. order_num=idx,
  573. user_id=user.id,
  574. dept_id=user.dept_id,
  575. )
  576. db.add(pp)
  577. plan.total_amount = calc_plan_total_amount(payload.products or [])
  578. db.commit()
  579. db.refresh(plan)
  580. return plan
  581. @router.post("/create-full", response_model=PlanWithProducts, status_code=status.HTTP_201_CREATED)
  582. def create_plan_full(
  583. payload: PlanCreateFull,
  584. db: Session = Depends(get_db),
  585. user: DbUserContext = Depends(get_db_current_user),
  586. ) -> PlanWithProducts:
  587. """创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)"""
  588. validate_create_full(payload)
  589. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  590. supplier_contracts, demander_contracts = validate_plan_party_contracts(
  591. db,
  592. tenant_no,
  593. supplier_id=payload.supplier_id,
  594. supplier_name=payload.supplier_name,
  595. customer_id=payload.customer_id,
  596. customer_name=payload.customer_name,
  597. supplier_contracts=payload.supplier_contracts,
  598. demander_contracts=payload.demander_contracts,
  599. require_products=payload.products,
  600. )
  601. plan_no = _generate_plan_no(db)
  602. # 单据类型由前端显式选择;未选则存空,不再自动推断为直销单
  603. plan_type = (payload.plan_type or "").strip()
  604. has_vehicle = bool(payload.transport and payload.transport.vehicle_id)
  605. demander_name = (payload.customer_name or "").strip() or None
  606. plan = PlPlan(
  607. id=generate_uuid(),
  608. tenant_no=tenant_no,
  609. plan_no=plan_no,
  610. create_date=_resolve_create_date(payload.create_date),
  611. plan_date=payload.plan_date,
  612. belongs_company=payload.belongs_company,
  613. plan_type=plan_type,
  614. follow_status="pending_arrange",
  615. transport_status="pending",
  616. archive_status="unarchived",
  617. complete_status="no",
  618. contract_id=None,
  619. contract_no=None,
  620. supplier_id=payload.supplier_id,
  621. supplier_name=payload.supplier_name,
  622. destination_id=payload.customer_id or payload.destination_id,
  623. destination_name=demander_name or payload.destination_name,
  624. customer_id=payload.customer_id,
  625. customer_name=payload.customer_name,
  626. contact_name=payload.contact_name,
  627. contact_phone=payload.contact_phone,
  628. party_a=payload.party_a,
  629. party_b=payload.party_b,
  630. contract_start_date=payload.contract_start_date,
  631. contract_end_date=payload.contract_end_date,
  632. delivery_method=payload.delivery_method,
  633. settlement_method=payload.settlement_method,
  634. has_freight=payload.has_freight,
  635. pickup_address=payload.pickup_address,
  636. pickup_lng=payload.pickup_lng,
  637. pickup_lat=payload.pickup_lat,
  638. pickup_time=payload.pickup_time,
  639. pickup_contact=payload.pickup_contact,
  640. pickup_notes=payload.pickup_notes,
  641. destination_address=payload.destination_address,
  642. destination_lng=payload.destination_lng,
  643. destination_lat=payload.destination_lat,
  644. delivery_time=payload.delivery_time,
  645. delivery_contact=payload.delivery_contact,
  646. destination_notes=payload.destination_notes,
  647. total_amount=calc_plan_total_amount(payload.products or []),
  648. follow_user_id=payload.follow_user_id or user.id,
  649. follow_user_name=payload.follow_user_name or user.nick_name,
  650. remark=payload.remark,
  651. create_by=user.id,
  652. create_dept_id=user.dept_id,
  653. )
  654. db.add(plan)
  655. db.flush()
  656. plan_products: list[PlPlanProduct] = []
  657. for idx, prod in enumerate(payload.products or []):
  658. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  659. db,
  660. tenant_no,
  661. prod.product_id,
  662. )
  663. prod_payload = prod.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
  664. pp = build_plan_product(
  665. tenant_no=tenant_no,
  666. plan_id=plan.id,
  667. plan_type=plan_type,
  668. prod=prod_payload,
  669. order_num=idx,
  670. user_id=user.id,
  671. dept_id=user.dept_id,
  672. )
  673. db.add(pp)
  674. plan_products.append(pp)
  675. replace_plan_contracts(
  676. db,
  677. tenant_no=tenant_no,
  678. plan_id=plan.id,
  679. supplier_contracts=supplier_contracts,
  680. demander_contracts=demander_contracts,
  681. user_id=user.id,
  682. dept_id=user.dept_id,
  683. )
  684. if has_vehicle and payload.transport:
  685. apply_vehicle_prefill_to_plan(
  686. db,
  687. tenant_no=tenant_no,
  688. plan=plan,
  689. transport_payload=payload.transport,
  690. products=plan_products,
  691. user_id=user.id,
  692. dept_id=user.dept_id,
  693. )
  694. db.commit()
  695. db.refresh(plan)
  696. result = PlanWithProducts.model_validate(plan)
  697. result.products = plan_products
  698. result.supplier_contracts = supplier_contracts
  699. result.demander_contracts = demander_contracts
  700. return result
  701. # =============================================================================
  702. # 从合同创建计划
  703. # =============================================================================
  704. @router.post("/create-from-contract", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  705. def create_from_contract(
  706. payload: CreateFromContractRequest,
  707. db: Session = Depends(get_db),
  708. user: DbUserContext = Depends(get_db_current_user),
  709. ) -> PlanRead:
  710. """从 CRM 合同发起计划"""
  711. from app.models.crm_models import CrmContract, CrmContractProduct
  712. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  713. # 查询合同信息
  714. contract = (
  715. db.query(CrmContract)
  716. .filter(CrmContract.id == payload.contract_id, CrmContract.del_flag == 0)
  717. .first()
  718. )
  719. if not contract:
  720. raise HTTPException(status_code=404, detail="合同不存在")
  721. assert_contract_selectable_for_plan(db, contract.id, tenant_no)
  722. # 生成计划编号
  723. plan_no = _generate_plan_no(db)
  724. # 自动判断单据类型
  725. plan_type = _determine_plan_type(contract.supplier_name, contract.customer_name)
  726. plan = PlPlan(
  727. id=generate_uuid(),
  728. tenant_no=tenant_no,
  729. plan_no=plan_no,
  730. create_date=_resolve_create_date(payload.create_date),
  731. plan_date=payload.plan_date,
  732. belongs_company=contract.belongs_company or "shanxia",
  733. plan_type=plan_type,
  734. follow_status="pending_arrange",
  735. transport_status="pending",
  736. archive_status="unarchived",
  737. complete_status="no",
  738. contract_id=contract.id,
  739. contract_no=contract.contract_no,
  740. supplier_id=contract.supplier_id,
  741. supplier_name=(contract.party_a or contract.supplier_name or "").strip() or None,
  742. destination_id=contract.customer_id,
  743. # 客户名称严格跟合同「客户名称」;目的地名称与客户保持一致(不再取需方可章名 party_b)
  744. destination_name=(contract.customer_name or "").strip() or None,
  745. customer_id=contract.customer_id,
  746. customer_name=(contract.customer_name or "").strip() or None,
  747. contact_name=contract.contact_name,
  748. contact_phone=contract.contact_phone,
  749. party_a=contract.party_a,
  750. party_b=contract.party_b,
  751. contract_start_date=contract.start_date,
  752. contract_end_date=contract.end_date,
  753. delivery_method=contract.delivery_method,
  754. settlement_method=contract.settlement_method,
  755. has_freight=1 if contract.settlement_method and "含运" in contract.settlement_method else 0,
  756. follow_user_id=payload.follow_user_id or user.id,
  757. follow_user_name=payload.follow_user_name or user.nick_name,
  758. create_by=user.id,
  759. create_dept_id=user.dept_id,
  760. )
  761. db.add(plan)
  762. # 查询合同产品明细
  763. contract_products = (
  764. db.query(CrmContractProduct)
  765. .filter(
  766. CrmContractProduct.contract_id == contract.id,
  767. CrmContractProduct.del_flag == 0,
  768. )
  769. .all()
  770. )
  771. # 添加产品明细
  772. for idx, cp in enumerate(contract_products):
  773. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  774. db,
  775. tenant_no,
  776. cp.product_id,
  777. )
  778. pp = PlPlanProduct(
  779. id=generate_uuid(),
  780. tenant_no=tenant_no,
  781. plan_id=plan.id,
  782. product_id=cp.product_id,
  783. product_no=cp.product_no,
  784. product_name=cp.product_name,
  785. brand_mode=cp.brand_mode,
  786. brand_id=cp.brand_id,
  787. brand_name=cp.brand_name,
  788. model=cp.model,
  789. unit=cp.unit or "吨",
  790. is_explosive=is_explosive,
  791. is_toxic=is_toxic,
  792. inbound_estimate_qty=cp.quantity,
  793. outbound_estimate_qty=cp.quantity,
  794. order_num=idx,
  795. create_by=user.id,
  796. create_dept_id=user.dept_id,
  797. )
  798. db.add(pp)
  799. db.commit()
  800. db.refresh(plan)
  801. return plan
  802. # =============================================================================
  803. # 获取计划详情
  804. # =============================================================================
  805. @router.get("/{id}", response_model=PlanWithProducts)
  806. def get_plan(
  807. id: str,
  808. db: Session = Depends(get_db),
  809. user: DbUserContext = Depends(get_db_current_user),
  810. ) -> PlanWithProducts:
  811. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  812. plan = _get_plan(db, tenant_no, id, user)
  813. products = (
  814. db.query(PlPlanProduct)
  815. .filter(
  816. PlPlanProduct.plan_id == id,
  817. PlPlanProduct.del_flag == 0,
  818. )
  819. .order_by(PlPlanProduct.order_num)
  820. .all()
  821. )
  822. hazard_map = fetch_hazard_flags_map(
  823. db,
  824. tenant_no,
  825. [product.product_id for product in products if product.product_id],
  826. )
  827. brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
  828. db,
  829. tenant_no=tenant_no,
  830. plan_product_ids=[p.id for p in products],
  831. )
  832. enriched_products = []
  833. for product in products:
  834. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  835. db,
  836. tenant_no,
  837. product.product_id,
  838. hazard_map=hazard_map,
  839. )
  840. item = PlanProductRead.model_validate(product)
  841. raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
  842. brand_qtys = (
  843. [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
  844. if raw_brand_qtys
  845. else None
  846. )
  847. item = item.model_copy(
  848. update={
  849. "is_toxic": is_toxic,
  850. "is_explosive": is_explosive,
  851. "brand_qtys": brand_qtys,
  852. }
  853. )
  854. enriched_products.append(item)
  855. result = PlanWithProducts.model_validate(plan)
  856. result.products = enriched_products
  857. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  858. db, tenant_no, [id]
  859. ).get(id, ([], []))
  860. result.supplier_contracts = supplier_contracts
  861. result.demander_contracts = demander_contracts
  862. return result
  863. # =============================================================================
  864. # 更新计划
  865. # =============================================================================
  866. @router.put("/{id}", response_model=PlanRead)
  867. def update_plan(
  868. id: str,
  869. payload: PlanUpdate,
  870. db: Session = Depends(get_db),
  871. user: DbUserContext = Depends(get_db_current_user),
  872. ) -> PlanRead:
  873. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  874. plan = _get_plan(db, tenant_no, id, user)
  875. if plan.follow_status == "cancelled":
  876. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  877. # 更新主表字段(跟进状态仅由业务动作接口维护,忽略客户端写入)
  878. update_data = payload.model_dump(
  879. exclude_unset=True,
  880. exclude={"products", "transport", "follow_status", "supplier_contracts", "demander_contracts"},
  881. )
  882. for key, value in update_data.items():
  883. setattr(plan, key, value)
  884. # 新模型:单一合同字段不再作为写入源
  885. if "supplier_contracts" in payload.model_fields_set or "demander_contracts" in payload.model_fields_set:
  886. plan.contract_id = None
  887. plan.contract_no = None
  888. if payload.customer_id is not None or payload.customer_name is not None:
  889. plan.destination_id = plan.customer_id
  890. plan.destination_name = plan.customer_name
  891. supplier_id = plan.supplier_id
  892. supplier_name = plan.supplier_name
  893. customer_id = plan.customer_id
  894. customer_name = plan.customer_name
  895. supplier_contracts_in = (
  896. payload.supplier_contracts
  897. if "supplier_contracts" in payload.model_fields_set
  898. else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[0]
  899. )
  900. demander_contracts_in = (
  901. payload.demander_contracts
  902. if "demander_contracts" in payload.model_fields_set
  903. else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[1]
  904. )
  905. # 编辑保存若带了产品或任一侧合同字段,按新规则完整校验供方/需方
  906. if (
  907. payload.products is not None
  908. or "supplier_contracts" in payload.model_fields_set
  909. or "demander_contracts" in payload.model_fields_set
  910. or payload.supplier_id is not None
  911. or payload.customer_id is not None
  912. ):
  913. supplier_contracts, demander_contracts = validate_plan_party_contracts(
  914. db,
  915. tenant_no,
  916. supplier_id=supplier_id,
  917. supplier_name=supplier_name,
  918. customer_id=customer_id,
  919. customer_name=customer_name,
  920. supplier_contracts=supplier_contracts_in,
  921. demander_contracts=demander_contracts_in,
  922. require_products=payload.products if payload.products is not None else None,
  923. )
  924. replace_plan_contracts(
  925. db,
  926. tenant_no=tenant_no,
  927. plan_id=id,
  928. supplier_contracts=supplier_contracts,
  929. demander_contracts=demander_contracts,
  930. user_id=user.id,
  931. dept_id=user.dept_id,
  932. )
  933. else:
  934. supplier_contracts, demander_contracts = load_plan_contracts_grouped(db, tenant_no, [id]).get(
  935. id, ([], [])
  936. )
  937. plan_type = payload.plan_type or plan.plan_type
  938. # 更新产品明细
  939. if payload.products is not None:
  940. existing_products = (
  941. db.query(PlPlanProduct)
  942. .filter(
  943. PlPlanProduct.plan_id == id,
  944. PlPlanProduct.del_flag == 0,
  945. )
  946. .all()
  947. )
  948. old_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, existing_products)
  949. cert_by_product_id = {
  950. row.product_id: (
  951. row.purchase_cert_url,
  952. row.transport_cert_url,
  953. row.legal_use_cert_url,
  954. )
  955. for row in existing_products
  956. }
  957. db.query(PlPlanProduct).filter(
  958. PlPlanProduct.plan_id == id,
  959. PlPlanProduct.del_flag == 0,
  960. ).update({"del_flag": 1})
  961. plan_products: list[PlPlanProduct] = []
  962. for idx, prod in enumerate(payload.products):
  963. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  964. db,
  965. tenant_no,
  966. prod.product_id,
  967. )
  968. purchase_cert = prod.purchase_cert_url
  969. transport_cert = prod.transport_cert_url
  970. legal_use_cert = prod.legal_use_cert_url
  971. old_certs = cert_by_product_id.get(prod.product_id, (None, None, None))
  972. old_purchase, old_transport, old_legal_use = old_certs
  973. purchase_cert = purchase_cert or old_purchase
  974. transport_cert = transport_cert or old_transport
  975. legal_use_cert = legal_use_cert or old_legal_use
  976. prod_payload = prod.model_copy(
  977. update={
  978. "is_toxic": is_toxic,
  979. "is_explosive": is_explosive,
  980. "purchase_cert_url": purchase_cert,
  981. "transport_cert_url": transport_cert,
  982. "legal_use_cert_url": legal_use_cert,
  983. }
  984. )
  985. pp = build_plan_product(
  986. tenant_no=tenant_no,
  987. plan_id=id,
  988. plan_type=plan_type,
  989. prod=prod_payload,
  990. order_num=idx,
  991. user_id=user.id,
  992. dept_id=user.dept_id,
  993. )
  994. db.add(pp)
  995. plan_products.append(pp)
  996. new_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, payload.products)
  997. if old_hazard_product_ids != new_hazard_product_ids:
  998. clear_hazard_cert_ready(plan)
  999. plan.total_amount = calc_plan_total_amount(payload.products)
  1000. else:
  1001. plan_products = (
  1002. db.query(PlPlanProduct)
  1003. .filter(
  1004. PlPlanProduct.plan_id == id,
  1005. PlPlanProduct.del_flag == 0,
  1006. )
  1007. .all()
  1008. )
  1009. # 同步运输预选 / 关联运输单运费
  1010. if payload.transport is not None:
  1011. if payload.transport.vehicle_id:
  1012. sync_transport_prefill_for_plan(
  1013. db,
  1014. tenant_no=tenant_no,
  1015. plan=plan,
  1016. transport_payload=payload.transport,
  1017. products=plan_products,
  1018. user_id=user.id,
  1019. dept_id=user.dept_id,
  1020. )
  1021. else:
  1022. clear_draft_transport_for_plan(
  1023. db,
  1024. tenant_no=tenant_no,
  1025. plan=plan,
  1026. user_id=user.id,
  1027. )
  1028. plan.update_by = user.id
  1029. db.commit()
  1030. db.refresh(plan)
  1031. result = PlanRead.model_validate(plan)
  1032. result.supplier_contracts = supplier_contracts
  1033. result.demander_contracts = demander_contracts
  1034. return result
  1035. @router.patch("/{id}/product-certs", response_model=PlanWithProducts)
  1036. def patch_plan_product_certs(
  1037. id: str,
  1038. payload: PlanProductCertsUpdate,
  1039. db: Session = Depends(get_db),
  1040. user: DbUserContext = Depends(get_db_current_user),
  1041. ) -> PlanWithProducts:
  1042. """更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)"""
  1043. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1044. _get_plan(db, tenant_no, id, user)
  1045. update_plan_product_certs(
  1046. db,
  1047. tenant_no=tenant_no,
  1048. plan_id=id,
  1049. items=payload.products,
  1050. user_id=user.id,
  1051. )
  1052. plan = _get_plan(db, tenant_no, id, user)
  1053. products = (
  1054. db.query(PlPlanProduct)
  1055. .filter(
  1056. PlPlanProduct.plan_id == id,
  1057. PlPlanProduct.del_flag == 0,
  1058. )
  1059. .order_by(PlPlanProduct.order_num)
  1060. .all()
  1061. )
  1062. result = PlanWithProducts.model_validate(plan)
  1063. result.products = products
  1064. return result
  1065. @router.patch("/{id}/hazard-cert-ready", response_model=PlanWithProducts)
  1066. def patch_plan_hazard_cert_ready(
  1067. id: str,
  1068. payload: HazardCertReadyBody,
  1069. db: Session = Depends(get_db),
  1070. user: DbUserContext = Depends(get_db_current_user),
  1071. ) -> PlanWithProducts:
  1072. """标记或取消危化品证书齐备(人工确认)"""
  1073. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1074. _get_plan(db, tenant_no, id, user)
  1075. set_hazard_cert_ready(
  1076. db,
  1077. tenant_no=tenant_no,
  1078. plan_id=id,
  1079. ready=payload.ready,
  1080. user_id=user.id,
  1081. )
  1082. plan = _get_plan(db, tenant_no, id, user)
  1083. products = (
  1084. db.query(PlPlanProduct)
  1085. .filter(
  1086. PlPlanProduct.plan_id == id,
  1087. PlPlanProduct.del_flag == 0,
  1088. )
  1089. .order_by(PlPlanProduct.order_num)
  1090. .all()
  1091. )
  1092. result = PlanWithProducts.model_validate(plan)
  1093. result.products = products
  1094. return result
  1095. def _build_driver_url(token: str) -> str:
  1096. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  1097. return f"{origin}/#/public/pl/transport/{token}"
  1098. @router.get("/{id}/arrange-info", response_model=PlanArrangeInfoRead)
  1099. def get_plan_arrange_info(
  1100. id: str,
  1101. db: Session = Depends(get_db),
  1102. user: DbUserContext = Depends(get_db_current_user),
  1103. ) -> PlanArrangeInfoRead:
  1104. """获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。"""
  1105. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1106. plan = _get_plan(db, tenant_no, id, user)
  1107. empty = PlanArrangeInfoRead(driver_url=None, license_plate=None, site_records=[], inventory_records=[])
  1108. if plan.follow_status == "cancelled" or plan.transport_status != "arranged" or not plan.transport_id:
  1109. return empty
  1110. transport = (
  1111. db.query(PlTransport)
  1112. .filter(
  1113. PlTransport.id == plan.transport_id,
  1114. PlTransport.tenant_no == tenant_no,
  1115. PlTransport.del_flag == 0,
  1116. )
  1117. .first()
  1118. )
  1119. if not transport or transport.transport_status == "cancelled":
  1120. return empty
  1121. token = (transport.driver_access_token or "").strip()
  1122. driver_url = _build_driver_url(token) if token else None
  1123. site_rows = (
  1124. db.query(PlTransportSiteLink)
  1125. .filter(
  1126. PlTransportSiteLink.tenant_no == tenant_no,
  1127. PlTransportSiteLink.plan_id == id,
  1128. PlTransportSiteLink.transport_id == transport.id,
  1129. PlTransportSiteLink.del_flag == 0,
  1130. )
  1131. .order_by(PlTransportSiteLink.create_time.asc())
  1132. .all()
  1133. )
  1134. product_name_by_id = {
  1135. item.id: item.product_name or ""
  1136. for item in db.query(PlPlanProduct)
  1137. .filter(
  1138. PlPlanProduct.plan_id == id,
  1139. PlPlanProduct.del_flag == 0,
  1140. )
  1141. .all()
  1142. }
  1143. site_records: list[TransportSiteLinkRead] = [
  1144. build_transport_site_link_read_from_row(
  1145. row,
  1146. product_name=product_name_by_id.get(row.plan_product_id, row.product or ""),
  1147. )
  1148. for row in site_rows
  1149. ]
  1150. links = (
  1151. db.query(PlTransportDbLink)
  1152. .filter(
  1153. PlTransportDbLink.tenant_no == tenant_no,
  1154. PlTransportDbLink.plan_id == id,
  1155. PlTransportDbLink.transport_id == transport.id,
  1156. PlTransportDbLink.del_flag == 0,
  1157. )
  1158. .all()
  1159. )
  1160. inventory_records: list[TransportInventoryLinkRead] = []
  1161. for link in links:
  1162. product = (
  1163. db.query(PlPlanProduct)
  1164. .filter(
  1165. PlPlanProduct.id == link.plan_product_id,
  1166. PlPlanProduct.del_flag == 0,
  1167. )
  1168. .first()
  1169. )
  1170. inventory = (
  1171. db.query(DbInboundOutbound)
  1172. .filter(
  1173. DbInboundOutbound.id == link.db_inventory_id,
  1174. DbInboundOutbound.tenant_no == tenant_no,
  1175. DbInboundOutbound.del_flag == 0,
  1176. )
  1177. .first()
  1178. )
  1179. inventory_records.append(
  1180. build_transport_inventory_link_read(
  1181. db,
  1182. plan_product_id=link.plan_product_id,
  1183. product_name=(product.product_name if product else "") or "",
  1184. db_purchase_plan_id=link.db_purchase_plan_id,
  1185. db_inventory_id=link.db_inventory_id,
  1186. batch_no=(inventory.batch_no if inventory else "") or "",
  1187. plan_no=plan.plan_no or "",
  1188. )
  1189. )
  1190. io_order_brief = None
  1191. active_io = get_active_io_order_for_plan(db, tenant_no, plan)
  1192. if active_io:
  1193. kind, header = active_io
  1194. from app.services.pl_io_order_service import build_io_order_site_url
  1195. token = (header.site_access_token or "").strip()
  1196. io_order_brief = TransportIoOrderBrief(
  1197. kind=kind,
  1198. order_id=header.id,
  1199. order_no=header.order_no,
  1200. site_access_token=token or None,
  1201. site_url=build_io_order_site_url(kind, token) if token else None,
  1202. status=header.status,
  1203. )
  1204. if not site_records and token and header.status != "voided":
  1205. site_records = [
  1206. TransportSiteLinkRead(
  1207. plan_product_id="",
  1208. product_name=header.order_no,
  1209. site_link_id=header.id,
  1210. plan_no=plan.plan_no or "",
  1211. business_type="入库" if kind == "inbound" else "出库",
  1212. site_access_token=token,
  1213. site_url=build_io_order_site_url(kind, token),
  1214. )
  1215. ]
  1216. return PlanArrangeInfoRead(
  1217. driver_url=driver_url,
  1218. license_plate=transport.license_plate or plan.license_plate,
  1219. site_records=site_records,
  1220. inventory_records=inventory_records,
  1221. io_order=io_order_brief,
  1222. )
  1223. # =============================================================================
  1224. # 跟进状态:等通知 / 取消等通知
  1225. # =============================================================================
  1226. @router.post("/{id}/wait-notice", response_model=PlanRead)
  1227. def mark_plan_wait_notice(
  1228. id: str,
  1229. db: Session = Depends(get_db),
  1230. user: DbUserContext = Depends(get_db_current_user),
  1231. ) -> PlanRead:
  1232. """将跟进状态从「待安排」设为「等通知」。"""
  1233. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1234. plan = _get_plan(db, tenant_no, id, user)
  1235. if plan.follow_status == "cancelled":
  1236. raise HTTPException(status_code=400, detail="已取消的计划不能标记等通知")
  1237. if plan.archive_status == "archived":
  1238. raise HTTPException(status_code=400, detail="已归档的计划不能标记等通知")
  1239. if plan.follow_status != "pending_arrange":
  1240. raise HTTPException(status_code=400, detail="仅待安排状态可标记为等通知")
  1241. plan.follow_status = "wait_notice"
  1242. plan.update_by = user.id
  1243. db.commit()
  1244. db.refresh(plan)
  1245. return plan
  1246. @router.post("/{id}/cancel-wait-notice", response_model=PlanRead)
  1247. def cancel_plan_wait_notice(
  1248. id: str,
  1249. db: Session = Depends(get_db),
  1250. user: DbUserContext = Depends(get_db_current_user),
  1251. ) -> PlanRead:
  1252. """将跟进状态从「等通知」改回「待安排」。"""
  1253. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1254. plan = _get_plan(db, tenant_no, id, user)
  1255. if plan.follow_status != "wait_notice":
  1256. raise HTTPException(status_code=400, detail="仅等通知状态可取消等通知")
  1257. if plan.archive_status == "archived":
  1258. raise HTTPException(status_code=400, detail="已归档的计划不能取消等通知")
  1259. plan.follow_status = "pending_arrange"
  1260. plan.update_by = user.id
  1261. db.commit()
  1262. db.refresh(plan)
  1263. return plan
  1264. FORCE_FOLLOW_STATUS_PERM = "pl:plan:force-follow-status"
  1265. ALLOWED_FORCE_FOLLOW_STATUSES = frozenset(
  1266. {"wait_notice", "pending_arrange", "arranged", "completed", "cancelled"}
  1267. )
  1268. @router.post("/{id}/force-follow-status", response_model=PlanRead)
  1269. def force_plan_follow_status(
  1270. id: str,
  1271. payload: PlanForceFollowStatusBody,
  1272. db: Session = Depends(get_db),
  1273. user: DbUserContext = Depends(get_db_current_user),
  1274. ) -> PlanRead:
  1275. """强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。"""
  1276. if user.role != SUPER_ADMIN_ROLE:
  1277. codes = get_user_menu_codes(db, user.id)
  1278. if FORCE_FOLLOW_STATUS_PERM not in codes:
  1279. raise HTTPException(status_code=403, detail="无强制修改跟进状态权限")
  1280. next_status = (payload.follow_status or "").strip()
  1281. if next_status not in ALLOWED_FORCE_FOLLOW_STATUSES:
  1282. raise HTTPException(
  1283. status_code=400,
  1284. detail="跟进状态仅支持:等通知/待安排/已安排/已完成/已取消",
  1285. )
  1286. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1287. plan = _get_plan(db, tenant_no, id, user)
  1288. plan.follow_status = next_status
  1289. plan.update_by = user.id
  1290. db.commit()
  1291. db.refresh(plan)
  1292. return plan
  1293. # =============================================================================
  1294. # 取消计划
  1295. # =============================================================================
  1296. @router.post("/{id}/cancel", response_model=PlanRead)
  1297. def cancel_plan(
  1298. id: str,
  1299. db: Session = Depends(get_db),
  1300. user: DbUserContext = Depends(get_db_current_user),
  1301. ) -> PlanRead:
  1302. """作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。"""
  1303. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1304. plan = _get_plan(db, tenant_no, id, user)
  1305. if plan.follow_status == "cancelled":
  1306. raise HTTPException(status_code=400, detail="计划已作废")
  1307. if plan.archive_status == "archived":
  1308. raise HTTPException(status_code=400, detail="已归档的计划不能作废")
  1309. if plan.transport_id:
  1310. transport = (
  1311. db.query(PlTransport)
  1312. .filter(
  1313. PlTransport.id == plan.transport_id,
  1314. PlTransport.tenant_no == tenant_no,
  1315. PlTransport.del_flag == 0,
  1316. )
  1317. .first()
  1318. )
  1319. if transport:
  1320. try:
  1321. revoke_regeneratable_dibang_for_transport(
  1322. db,
  1323. tenant_no=tenant_no,
  1324. transport_id=transport.id,
  1325. user_id=user.id,
  1326. )
  1327. except ValueError as exc:
  1328. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1329. revoke_site_links_for_transport(
  1330. db,
  1331. tenant_no=tenant_no,
  1332. transport_id=transport.id,
  1333. user_id=user.id,
  1334. )
  1335. transport.transport_status = "cancelled"
  1336. transport.update_by = user.id
  1337. void_io_orders_for_plan(
  1338. db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id
  1339. )
  1340. plan.follow_status = "cancelled"
  1341. plan.transport_status = "pending"
  1342. plan.update_by = user.id
  1343. db.commit()
  1344. db.refresh(plan)
  1345. return plan
  1346. # =============================================================================
  1347. # 归档计划
  1348. # =============================================================================
  1349. @router.post("/{id}/archive", response_model=PlanWithProducts)
  1350. def archive_plan(
  1351. id: str,
  1352. payload: Optional[PlanArchiveBody] = Body(default=None),
  1353. db: Session = Depends(get_db),
  1354. user: DbUserContext = Depends(get_db_current_user),
  1355. ) -> PlanWithProducts:
  1356. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1357. plan = _get_plan(db, tenant_no, id, user)
  1358. if plan.follow_status == "cancelled":
  1359. raise HTTPException(status_code=400, detail="已作废的计划不能归档")
  1360. already_archived = plan.archive_status == "archived"
  1361. if not already_archived:
  1362. products = (
  1363. db.query(PlPlanProduct)
  1364. .filter(
  1365. PlPlanProduct.plan_id == plan.id,
  1366. PlPlanProduct.tenant_no == tenant_no,
  1367. PlPlanProduct.del_flag == 0,
  1368. )
  1369. .order_by(PlPlanProduct.order_num)
  1370. .all()
  1371. )
  1372. validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
  1373. if payload is None:
  1374. if already_archived:
  1375. raise HTTPException(status_code=400, detail="计划已归档,请提交归档表单以调整内容")
  1376. plan.archive_status = "archived"
  1377. plan.complete_status = "yes"
  1378. plan.follow_status = "completed"
  1379. plan.update_by = user.id
  1380. db.commit()
  1381. return get_plan(id, db, user)
  1382. apply_archive_product_quantities(
  1383. db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
  1384. )
  1385. apply_archive_inventory_for_plan(
  1386. db,
  1387. tenant_no=tenant_no,
  1388. plan=plan,
  1389. user_id=user.id,
  1390. dept_id=user.dept_id or "",
  1391. )
  1392. if payload.freight is not None:
  1393. apply_archive_freight(
  1394. db, tenant_no=tenant_no, plan=plan, freight=payload.freight, user_id=user.id
  1395. )
  1396. elif int(plan.has_freight or 0) == 1 and not already_archived:
  1397. if not plan.transport_id:
  1398. raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
  1399. if not already_archived:
  1400. plan.archive_status = "archived"
  1401. plan.complete_status = "yes"
  1402. plan.follow_status = "completed"
  1403. plan.update_by = user.id
  1404. record_plan_change(
  1405. db,
  1406. tenant_no=tenant_no,
  1407. plan_id=plan.id,
  1408. change_type="archive_adjust" if already_archived else "archive",
  1409. change_content="调整归档内容" if already_archived else "运输计划归档",
  1410. user_id=user.id,
  1411. user_name=user.name or None,
  1412. dept_id=user.dept_id,
  1413. )
  1414. db.commit()
  1415. return get_plan(id, db, user)
  1416. @router.patch("/{id}/driver-feedback", response_model=PlanWithProducts)
  1417. def update_plan_driver_feedback(
  1418. id: str,
  1419. payload: PlanDriverFeedbackUpdateBody,
  1420. db: Session = Depends(get_db),
  1421. user: DbUserContext = Depends(get_db_current_user),
  1422. ) -> PlanWithProducts:
  1423. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1424. plan = _get_plan(db, tenant_no, id, user)
  1425. apply_plan_driver_feedback(
  1426. db,
  1427. tenant_no=tenant_no,
  1428. plan=plan,
  1429. payload=payload,
  1430. user_id=user.id,
  1431. )
  1432. record_plan_change(
  1433. db,
  1434. tenant_no=tenant_no,
  1435. plan_id=plan.id,
  1436. change_type="feedback_adjust",
  1437. change_content="归档人员更新司机回传信息",
  1438. user_id=user.id,
  1439. user_name=user.name or None,
  1440. dept_id=user.dept_id,
  1441. )
  1442. plan.update_by = user.id
  1443. db.commit()
  1444. return get_plan(id, db, user)
  1445. @router.patch("/{id}/site-feedback/{link_id}", response_model=PlanWithProducts)
  1446. def update_plan_site_feedback(
  1447. id: str,
  1448. link_id: str,
  1449. payload: PlanSiteFeedbackUpdateBody,
  1450. db: Session = Depends(get_db),
  1451. user: DbUserContext = Depends(get_db_current_user),
  1452. ) -> PlanWithProducts:
  1453. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1454. plan = _get_plan(db, tenant_no, id, user)
  1455. link = apply_plan_site_feedback(
  1456. db,
  1457. tenant_no=tenant_no,
  1458. plan=plan,
  1459. link_id=link_id,
  1460. payload=payload,
  1461. user_id=user.id,
  1462. )
  1463. product_label = (link.product or "现场").strip()
  1464. record_plan_change(
  1465. db,
  1466. tenant_no=tenant_no,
  1467. plan_id=plan.id,
  1468. change_type="feedback_adjust",
  1469. change_content=f"归档人员更新现场回传信息({product_label})",
  1470. user_id=user.id,
  1471. user_name=user.name or None,
  1472. dept_id=user.dept_id,
  1473. )
  1474. plan.update_by = user.id
  1475. db.commit()
  1476. return get_plan(id, db, user)
  1477. # =============================================================================
  1478. # 入库单打印模板
  1479. # =============================================================================
  1480. @router.get("/{id}/inbound-slip-template")
  1481. def get_inbound_slip_template(
  1482. id: str,
  1483. db: Session = Depends(get_db),
  1484. user: DbUserContext = Depends(get_db_current_user),
  1485. ) -> str:
  1486. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1487. plan = _get_plan(db, tenant_no, id, user)
  1488. return plan.slip_template_content or ""
  1489. @router.put("/{id}/inbound-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  1490. def save_inbound_slip_template(
  1491. id: str,
  1492. payload: PlanInboundSlipTemplateSave,
  1493. db: Session = Depends(get_db),
  1494. user: DbUserContext = Depends(get_db_current_user),
  1495. ) -> None:
  1496. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1497. plan = _get_plan(db, tenant_no, id, user)
  1498. if plan.plan_type != "inbound":
  1499. raise HTTPException(status_code=400, detail="仅入库单计划支持保存入库单打印模板")
  1500. if plan.transport_status != "arranged":
  1501. raise HTTPException(status_code=400, detail="请先完成运输安排后再保存入库单打印模板")
  1502. plan.slip_template_content = payload.slip_template_content or ""
  1503. plan.update_by = user.id
  1504. db.commit()
  1505. # =============================================================================
  1506. # 送(提)货单打印模板
  1507. # =============================================================================
  1508. @router.get("/{id}/delivery-slip-template")
  1509. def get_delivery_slip_template(
  1510. id: str,
  1511. db: Session = Depends(get_db),
  1512. user: DbUserContext = Depends(get_db_current_user),
  1513. ) -> str:
  1514. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1515. plan = _get_plan(db, tenant_no, id, user)
  1516. return plan.delivery_slip_template_content or ""
  1517. @router.put("/{id}/delivery-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  1518. def save_delivery_slip_template(
  1519. id: str,
  1520. payload: PlanDeliverySlipTemplateSave,
  1521. db: Session = Depends(get_db),
  1522. user: DbUserContext = Depends(get_db_current_user),
  1523. ) -> None:
  1524. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1525. plan = _get_plan(db, tenant_no, id, user)
  1526. if plan.plan_type == "inbound":
  1527. raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
  1528. if plan.transport_status != "arranged":
  1529. raise HTTPException(status_code=400, detail="请先完成运输安排后再保存送(提)货单打印模板")
  1530. plan.delivery_slip_template_content = payload.delivery_slip_template_content or ""
  1531. plan.update_by = user.id
  1532. db.commit()
  1533. # =============================================================================
  1534. # 分装单打印模板
  1535. # =============================================================================
  1536. @router.get("/{id}/subpack-slip-template")
  1537. def get_subpack_slip_template(
  1538. id: str,
  1539. db: Session = Depends(get_db),
  1540. user: DbUserContext = Depends(get_db_current_user),
  1541. ) -> str:
  1542. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1543. plan = _get_plan(db, tenant_no, id, user)
  1544. return plan.subpack_slip_template_content or ""
  1545. @router.put("/{id}/subpack-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  1546. def save_subpack_slip_template(
  1547. id: str,
  1548. payload: PlanSubpackSlipTemplateSave,
  1549. db: Session = Depends(get_db),
  1550. user: DbUserContext = Depends(get_db_current_user),
  1551. ) -> None:
  1552. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1553. plan = _get_plan(db, tenant_no, id, user)
  1554. if plan.plan_type == "inbound":
  1555. raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
  1556. plan.subpack_slip_template_content = payload.subpack_slip_template_content or ""
  1557. plan.update_by = user.id
  1558. db.commit()
  1559. @router.post("/{id}/subpack-slip-no")
  1560. def ensure_subpack_slip_no(
  1561. id: str,
  1562. db: Session = Depends(get_db),
  1563. user: DbUserContext = Depends(get_db_current_user),
  1564. ) -> str:
  1565. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1566. plan = _get_plan(db, tenant_no, id, user)
  1567. if plan.plan_type == "inbound":
  1568. raise HTTPException(status_code=400, detail="入库单计划不支持分装单号")
  1569. slip_no = _ensure_subpack_slip_no(db, tenant_no, plan)
  1570. plan.update_by = user.id
  1571. db.commit()
  1572. return slip_no
  1573. # =============================================================================
  1574. # 删除计划
  1575. # =============================================================================
  1576. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  1577. def delete_plan(
  1578. id: str,
  1579. db: Session = Depends(get_db),
  1580. user: DbUserContext = Depends(get_db_current_user),
  1581. ) -> None:
  1582. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1583. plan = _get_plan(db, tenant_no, id, user)
  1584. if plan.follow_status not in ("pending_arrange", "wait_notice"):
  1585. raise HTTPException(status_code=400, detail="只能删除待安排或等通知且未安排运输的计划")
  1586. if plan.transport_status != "pending":
  1587. raise HTTPException(status_code=400, detail="已安排运输的计划不能删除")
  1588. if plan.archive_status == "archived":
  1589. raise HTTPException(status_code=400, detail="已归档的计划不能删除")
  1590. assert_row_deletable_by_creator(plan, user_info_from_context(user))
  1591. plan.del_flag = 1
  1592. plan.update_by = user.id
  1593. db.query(PlPlanProduct).filter(
  1594. PlPlanProduct.plan_id == id,
  1595. PlPlanProduct.tenant_no == tenant_no,
  1596. PlPlanProduct.del_flag == 0,
  1597. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  1598. db.query(PlPlanTransport).filter(
  1599. PlPlanTransport.plan_id == id,
  1600. PlPlanTransport.tenant_no == tenant_no,
  1601. PlPlanTransport.del_flag == 0,
  1602. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  1603. db.commit()
  1604. # =============================================================================
  1605. # 辅助函数
  1606. # =============================================================================
  1607. def _get_plan(db: Session, tenant_no: str, plan_id: str, user: DbUserContext) -> PlPlan:
  1608. plan = (
  1609. db.query(PlPlan)
  1610. .filter(
  1611. PlPlan.id == plan_id,
  1612. PlPlan.tenant_no == tenant_no,
  1613. PlPlan.del_flag == 0,
  1614. )
  1615. .first()
  1616. )
  1617. if not plan:
  1618. raise HTTPException(status_code=404, detail="计划不存在")
  1619. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  1620. return plan