plan.py 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009
  1. """运输计划管理 - 计划 API"""
  2. from datetime import date
  3. from typing import Optional
  4. from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
  5. from sqlalchemy import func, or_
  6. from sqlalchemy.orm import Session
  7. from app.core.config import settings
  8. from app.core.database import get_db
  9. from app.core.db_auth import DbUserContext, get_db_current_user
  10. from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
  11. from app.core.tenant_context import get_tenant_no
  12. from app.core.utils import generate_uuid
  13. from app.models.crm_models import CrmContract, CrmContractProduct, CrmProduct
  14. from app.models.db_models import DbInboundOutbound
  15. from app.models.pl_models import (
  16. PlCarrier,
  17. PlDriver,
  18. PlPlan,
  19. PlPlanProduct,
  20. PlPlanTransport,
  21. PlTransport,
  22. PlTransportDbLink,
  23. PlTransportSiteLink,
  24. PlVehicle,
  25. )
  26. from app.schemas.pl import PlanProductCreate
  27. from app.schemas.pl.plan import (
  28. CreateFromContractRequest,
  29. PlanArchiveBody,
  30. PlanCreate,
  31. PlanCreateFull,
  32. PlanListQuery,
  33. PlanListResponse,
  34. PlanProductCertsUpdate,
  35. PlanProductRead,
  36. PlanRead,
  37. PlanUpdate,
  38. PlanWithProducts,
  39. )
  40. from app.schemas.pl.transport import PlanArrangeInfoRead, TransportInventoryLinkRead, TransportSiteLinkRead
  41. from app.services.pl_dibang_bridge_service import revoke_regeneratable_dibang_for_transport
  42. from app.services.pl_inventory_link_read import build_transport_inventory_link_read
  43. from app.services.pl_site_link_read import build_transport_site_link_read_from_row
  44. from app.services.pl_transport_site_service import revoke_site_links_for_transport
  45. from app.services.pl_plan_service import (
  46. apply_archive_freight,
  47. apply_archive_product_quantities,
  48. apply_vehicle_prefill_to_plan,
  49. build_plan_product,
  50. calc_plan_total_amount,
  51. clear_draft_transport_for_plan,
  52. determine_plan_type,
  53. record_plan_change,
  54. resolve_hazard_flags_from_crm,
  55. sync_transport_prefill_for_plan,
  56. update_plan_product_certs,
  57. validate_create_full,
  58. )
  59. router = APIRouter(prefix="/plan", tags=["运输计划-计划管理"])
  60. def _generate_plan_no(db: Session) -> str:
  61. """生成计划编号 SXDDHYYYYMMDD######"""
  62. today = date.today()
  63. prefix = f"SXDDH{today.strftime('%Y%m%d')}"
  64. # uk_plan_no 对全表生效(含软删),取号时不能过滤 del_flag
  65. last_plan = (
  66. db.query(PlPlan)
  67. .filter(PlPlan.plan_no.like(f"{prefix}%"))
  68. .order_by(PlPlan.plan_no.desc())
  69. .first()
  70. )
  71. if last_plan:
  72. last_seq = int(last_plan.plan_no[-6:])
  73. seq = last_seq + 1
  74. else:
  75. seq = 1
  76. return f"{prefix}{seq:06d}"
  77. def _determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
  78. """根据供应商和目的地自动判断单据类型"""
  79. return determine_plan_type(supplier_name, destination_name)
  80. def _get_plan_type_label(plan_type: str) -> str:
  81. labels = {
  82. "direct": "直销单",
  83. "inbound": "入库单",
  84. "outbound": "出库单",
  85. }
  86. return labels.get(plan_type, plan_type)
  87. def _generate_notify_message(plan: PlPlan, products: list) -> str:
  88. """生成通知信息"""
  89. product_names = "、".join([p.product_name for p in products[:3]])
  90. if len(products) > 3:
  91. product_names += "等"
  92. return f"""计划号:{plan.plan_no}
  93. 企业名称:{plan.customer_name or ''}
  94. 提货单位:{plan.destination_name or ''}
  95. 到货时间:{plan.contract_end_date or ''}
  96. 药品名称:{product_names}
  97. 到货吨位:{sum(float(p.inbound_estimate_qty or p.outbound_estimate_qty or 0) for p in products)}吨
  98. 备注:"""
  99. # =============================================================================
  100. # 计划列表
  101. # =============================================================================
  102. @router.get("/", response_model=PlanListResponse)
  103. def list_plans(
  104. q: Optional[str] = Query(default=None, description="模糊搜索"),
  105. plan_no: Optional[str] = Query(default=None, description="计划编号"),
  106. plan_type: Optional[str] = Query(default=None, alias="planType", description="单据类型"),
  107. follow_status: Optional[str] = Query(default=None, alias="followStatus", description="跟进状态"),
  108. transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
  109. archive_status: Optional[str] = Query(default=None, alias="archiveStatus", description="归档状态"),
  110. customer_name: Optional[str] = Query(default=None, alias="customerName", description="客户名称"),
  111. plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom", description="计划日期开始"),
  112. plan_date_to: Optional[date] = Query(default=None, alias="planDateTo", description="计划日期结束"),
  113. page: int = Query(default=1, ge=1),
  114. page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
  115. db: Session = Depends(get_db),
  116. user: DbUserContext = Depends(get_db_current_user),
  117. ) -> PlanListResponse:
  118. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  119. query = db.query(PlPlan).filter(
  120. PlPlan.tenant_no == tenant_no,
  121. PlPlan.del_flag == 0,
  122. )
  123. if q:
  124. query = query.filter(
  125. or_(
  126. PlPlan.plan_no.contains(q),
  127. func.lower(PlPlan.customer_name).contains(q.lower()),
  128. )
  129. )
  130. if plan_no:
  131. query = query.filter(PlPlan.plan_no.contains(plan_no))
  132. if plan_type:
  133. query = query.filter(PlPlan.plan_type == plan_type)
  134. if follow_status:
  135. query = query.filter(PlPlan.follow_status == follow_status)
  136. if transport_status:
  137. query = query.filter(PlPlan.transport_status == transport_status)
  138. if archive_status:
  139. query = query.filter(PlPlan.archive_status == archive_status)
  140. if customer_name:
  141. query = query.filter(func.lower(PlPlan.customer_name).contains(customer_name.lower()))
  142. if plan_date_from:
  143. query = query.filter(PlPlan.plan_date >= plan_date_from)
  144. if plan_date_to:
  145. query = query.filter(PlPlan.plan_date <= plan_date_to)
  146. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  147. # 统计总数
  148. total = query.count()
  149. # 分页
  150. offset = (page - 1) * page_size
  151. plans = query.order_by(PlPlan.create_time.desc()).offset(offset).limit(page_size).all()
  152. plan_ids = [plan.id for plan in plans]
  153. products_by_plan: dict[str, list[PlPlanProduct]] = {}
  154. if plan_ids:
  155. all_products = (
  156. db.query(PlPlanProduct)
  157. .filter(
  158. PlPlanProduct.plan_id.in_(plan_ids),
  159. PlPlanProduct.del_flag == 0,
  160. )
  161. .order_by(PlPlanProduct.order_num)
  162. .all()
  163. )
  164. for product in all_products:
  165. products_by_plan.setdefault(product.plan_id, []).append(product)
  166. result_list: list[PlanWithProducts] = []
  167. for plan in plans:
  168. enriched_products: list[PlanProductRead] = []
  169. for product in products_by_plan.get(plan.id, []):
  170. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  171. db,
  172. tenant_no,
  173. product.product_id,
  174. product.is_toxic,
  175. product.is_explosive,
  176. )
  177. item = PlanProductRead.model_validate(product)
  178. if is_toxic != int(product.is_toxic or 0) or is_explosive != int(product.is_explosive or 0):
  179. item = item.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
  180. enriched_products.append(item)
  181. plan_item = PlanWithProducts.model_validate(plan)
  182. plan_item.products = enriched_products
  183. result_list.append(plan_item)
  184. return PlanListResponse(total=total, list=result_list)
  185. @router.get("/count-by-contract")
  186. def count_plans_by_contract(
  187. contract_ids: str = Query(..., alias="contractIds", description="合同ID,逗号分隔"),
  188. db: Session = Depends(get_db),
  189. user: DbUserContext = Depends(get_db_current_user),
  190. ) -> dict:
  191. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  192. ids = [item.strip() for item in contract_ids.split(",") if item.strip()]
  193. if not ids:
  194. return {"counts": {}}
  195. rows = (
  196. db.query(PlPlan.contract_id, func.count(PlPlan.id))
  197. .filter(
  198. PlPlan.tenant_no == tenant_no,
  199. PlPlan.del_flag == 0,
  200. PlPlan.contract_id.in_(ids),
  201. )
  202. )
  203. rows = apply_pl_scope(rows, PlPlan, user, db, tenant_no).group_by(PlPlan.contract_id).all()
  204. return {"counts": {contract_id: count for contract_id, count in rows}}
  205. # =============================================================================
  206. # 创建计划
  207. # =============================================================================
  208. @router.post("/", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  209. def create_plan(
  210. payload: PlanCreate,
  211. db: Session = Depends(get_db),
  212. user: DbUserContext = Depends(get_db_current_user),
  213. ) -> PlanRead:
  214. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  215. # 生成计划编号
  216. plan_no = _generate_plan_no(db)
  217. # 自动判断单据类型
  218. plan_type = payload.plan_type
  219. if not plan_type:
  220. plan_type = _determine_plan_type(payload.supplier_name, payload.destination_name)
  221. plan = PlPlan(
  222. id=generate_uuid(),
  223. tenant_no=tenant_no,
  224. plan_no=plan_no,
  225. plan_date=payload.plan_date,
  226. belongs_company=payload.belongs_company,
  227. plan_type=plan_type,
  228. follow_status=payload.follow_status or "draft",
  229. transport_status=payload.transport_status or "pending",
  230. archive_status=payload.archive_status or "unarchived",
  231. complete_status=payload.complete_status or "no",
  232. contract_id=payload.contract_id,
  233. contract_no=payload.contract_no,
  234. supplier_id=payload.supplier_id,
  235. supplier_name=payload.supplier_name,
  236. destination_id=payload.destination_id,
  237. destination_name=payload.destination_name,
  238. customer_id=payload.customer_id,
  239. customer_name=payload.customer_name,
  240. contact_name=payload.contact_name,
  241. contact_phone=payload.contact_phone,
  242. party_a=payload.party_a,
  243. party_b=payload.party_b,
  244. contract_start_date=payload.contract_start_date,
  245. contract_end_date=payload.contract_end_date,
  246. delivery_method=payload.delivery_method,
  247. settlement_method=payload.settlement_method,
  248. has_freight=payload.has_freight,
  249. pickup_address=payload.pickup_address,
  250. pickup_time=payload.pickup_time,
  251. pickup_notes=payload.pickup_notes,
  252. destination_address=payload.destination_address,
  253. delivery_time=payload.delivery_time,
  254. destination_notes=payload.destination_notes,
  255. follow_user_id=payload.follow_user_id or user.id,
  256. follow_user_name=payload.follow_user_name or user.nick_name,
  257. remark=payload.remark,
  258. create_by=user.id,
  259. create_dept_id=user.dept_id,
  260. )
  261. db.add(plan)
  262. # 添加产品明细
  263. if payload.products:
  264. for idx, prod in enumerate(payload.products):
  265. pp = build_plan_product(
  266. tenant_no=tenant_no,
  267. plan_id=plan.id,
  268. plan_type=plan_type,
  269. prod=prod,
  270. order_num=idx,
  271. user_id=user.id,
  272. dept_id=user.dept_id,
  273. )
  274. db.add(pp)
  275. plan.total_amount = calc_plan_total_amount(payload.products or [])
  276. db.commit()
  277. db.refresh(plan)
  278. return plan
  279. @router.post("/create-full", response_model=PlanWithProducts, status_code=status.HTTP_201_CREATED)
  280. def create_plan_full(
  281. payload: PlanCreateFull,
  282. db: Session = Depends(get_db),
  283. user: DbUserContext = Depends(get_db_current_user),
  284. ) -> PlanWithProducts:
  285. """创建运输计划(完整表单:合同产品 + 可选预选车辆,不自动安排运输)"""
  286. validate_create_full(payload)
  287. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  288. plan_no = _generate_plan_no(db)
  289. plan_type = payload.plan_type or _determine_plan_type(payload.supplier_name, payload.destination_name)
  290. has_vehicle = bool(payload.transport and payload.transport.vehicle_id)
  291. plan = PlPlan(
  292. id=generate_uuid(),
  293. tenant_no=tenant_no,
  294. plan_no=plan_no,
  295. plan_date=payload.plan_date,
  296. belongs_company=payload.belongs_company,
  297. plan_type=plan_type,
  298. follow_status="draft",
  299. transport_status="pending",
  300. archive_status="unarchived",
  301. complete_status="no",
  302. contract_id=payload.contract_id,
  303. contract_no=payload.contract_no,
  304. supplier_id=payload.supplier_id,
  305. supplier_name=payload.supplier_name,
  306. destination_id=payload.destination_id,
  307. destination_name=payload.destination_name,
  308. customer_id=payload.customer_id,
  309. customer_name=payload.customer_name,
  310. contact_name=payload.contact_name,
  311. contact_phone=payload.contact_phone,
  312. party_a=payload.party_a,
  313. party_b=payload.party_b,
  314. contract_start_date=payload.contract_start_date,
  315. contract_end_date=payload.contract_end_date,
  316. delivery_method=payload.delivery_method,
  317. settlement_method=payload.settlement_method,
  318. has_freight=payload.has_freight,
  319. pickup_address=payload.pickup_address,
  320. pickup_time=payload.pickup_time,
  321. pickup_notes=payload.pickup_notes,
  322. destination_address=payload.destination_address,
  323. delivery_time=payload.delivery_time,
  324. destination_notes=payload.destination_notes,
  325. total_amount=calc_plan_total_amount(payload.products or []),
  326. follow_user_id=payload.follow_user_id or user.id,
  327. follow_user_name=payload.follow_user_name or user.nick_name,
  328. remark=payload.remark,
  329. create_by=user.id,
  330. create_dept_id=user.dept_id,
  331. )
  332. db.add(plan)
  333. db.flush()
  334. plan_products: list[PlPlanProduct] = []
  335. for idx, prod in enumerate(payload.products or []):
  336. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  337. db,
  338. tenant_no,
  339. prod.product_id,
  340. prod.is_toxic,
  341. prod.is_explosive,
  342. )
  343. prod_payload = prod.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
  344. pp = build_plan_product(
  345. tenant_no=tenant_no,
  346. plan_id=plan.id,
  347. plan_type=plan_type,
  348. prod=prod_payload,
  349. order_num=idx,
  350. user_id=user.id,
  351. dept_id=user.dept_id,
  352. )
  353. db.add(pp)
  354. plan_products.append(pp)
  355. if has_vehicle and payload.transport:
  356. apply_vehicle_prefill_to_plan(
  357. db,
  358. tenant_no=tenant_no,
  359. plan=plan,
  360. transport_payload=payload.transport,
  361. products=plan_products,
  362. user_id=user.id,
  363. dept_id=user.dept_id,
  364. )
  365. db.commit()
  366. db.refresh(plan)
  367. result = PlanWithProducts.model_validate(plan)
  368. result.products = plan_products
  369. return result
  370. # =============================================================================
  371. # 从合同创建计划
  372. # =============================================================================
  373. @router.post("/create-from-contract", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  374. def create_from_contract(
  375. payload: CreateFromContractRequest,
  376. db: Session = Depends(get_db),
  377. user: DbUserContext = Depends(get_db_current_user),
  378. ) -> PlanRead:
  379. """从 CRM 合同发起计划"""
  380. from app.models.crm_models import CrmContract, CrmContractProduct
  381. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  382. # 查询合同信息
  383. contract = (
  384. db.query(CrmContract)
  385. .filter(CrmContract.id == payload.contract_id, CrmContract.del_flag == 0)
  386. .first()
  387. )
  388. if not contract:
  389. raise HTTPException(status_code=404, detail="合同不存在")
  390. # 生成计划编号
  391. plan_no = _generate_plan_no(db)
  392. # 自动判断单据类型
  393. plan_type = _determine_plan_type(contract.supplier_name, contract.customer_name)
  394. plan = PlPlan(
  395. id=generate_uuid(),
  396. tenant_no=tenant_no,
  397. plan_no=plan_no,
  398. plan_date=payload.plan_date,
  399. belongs_company=contract.belongs_company or "shanxia",
  400. plan_type=plan_type,
  401. follow_status="draft",
  402. transport_status="pending",
  403. archive_status="unarchived",
  404. complete_status="no",
  405. contract_id=contract.id,
  406. contract_no=contract.contract_no,
  407. supplier_id=contract.supplier_id,
  408. supplier_name=(contract.party_a or contract.supplier_name or "").strip() or None,
  409. destination_id=contract.customer_id,
  410. destination_name=(contract.party_b or contract.customer_name or "").strip() or None,
  411. customer_id=contract.customer_id,
  412. customer_name=(contract.party_b or contract.customer_name or "").strip() or None,
  413. contact_name=contract.contact_name,
  414. contact_phone=contract.contact_phone,
  415. party_a=contract.party_a,
  416. party_b=contract.party_b,
  417. contract_start_date=contract.start_date,
  418. contract_end_date=contract.end_date,
  419. delivery_method=contract.delivery_method,
  420. settlement_method=contract.settlement_method,
  421. has_freight=1 if contract.settlement_method and "含运" in contract.settlement_method else 0,
  422. follow_user_id=payload.follow_user_id or user.id,
  423. follow_user_name=payload.follow_user_name or user.nick_name,
  424. create_by=user.id,
  425. create_dept_id=user.dept_id,
  426. )
  427. db.add(plan)
  428. # 查询合同产品明细
  429. contract_products = (
  430. db.query(CrmContractProduct)
  431. .filter(
  432. CrmContractProduct.contract_id == contract.id,
  433. CrmContractProduct.del_flag == 0,
  434. )
  435. .all()
  436. )
  437. # 添加产品明细
  438. for idx, cp in enumerate(contract_products):
  439. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  440. db,
  441. tenant_no,
  442. cp.product_id,
  443. 0,
  444. 0,
  445. )
  446. pp = PlPlanProduct(
  447. id=generate_uuid(),
  448. tenant_no=tenant_no,
  449. plan_id=plan.id,
  450. product_id=cp.product_id,
  451. product_no=cp.product_no,
  452. product_name=cp.product_name,
  453. model=cp.specification,
  454. unit=cp.unit or "吨",
  455. is_explosive=is_explosive,
  456. is_toxic=is_toxic,
  457. inbound_estimate_qty=cp.quantity,
  458. outbound_estimate_qty=cp.quantity,
  459. order_num=idx,
  460. create_by=user.id,
  461. create_dept_id=user.dept_id,
  462. )
  463. db.add(pp)
  464. db.commit()
  465. db.refresh(plan)
  466. return plan
  467. # =============================================================================
  468. # 获取计划详情
  469. # =============================================================================
  470. @router.get("/{id}", response_model=PlanWithProducts)
  471. def get_plan(
  472. id: str,
  473. db: Session = Depends(get_db),
  474. user: DbUserContext = Depends(get_db_current_user),
  475. ) -> PlanWithProducts:
  476. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  477. plan = _get_plan(db, tenant_no, id, user)
  478. products = (
  479. db.query(PlPlanProduct)
  480. .filter(
  481. PlPlanProduct.plan_id == id,
  482. PlPlanProduct.del_flag == 0,
  483. )
  484. .order_by(PlPlanProduct.order_num)
  485. .all()
  486. )
  487. enriched_products = []
  488. for product in products:
  489. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  490. db,
  491. tenant_no,
  492. product.product_id,
  493. product.is_toxic,
  494. product.is_explosive,
  495. )
  496. item = PlanProductRead.model_validate(product)
  497. if is_toxic != int(product.is_toxic or 0) or is_explosive != int(product.is_explosive or 0):
  498. item = item.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
  499. enriched_products.append(item)
  500. result = PlanWithProducts.model_validate(plan)
  501. result.products = enriched_products
  502. return result
  503. # =============================================================================
  504. # 更新计划
  505. # =============================================================================
  506. @router.put("/{id}", response_model=PlanRead)
  507. def update_plan(
  508. id: str,
  509. payload: PlanUpdate,
  510. db: Session = Depends(get_db),
  511. user: DbUserContext = Depends(get_db_current_user),
  512. ) -> PlanRead:
  513. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  514. plan = _get_plan(db, tenant_no, id, user)
  515. # 更新主表字段
  516. update_data = payload.model_dump(exclude_unset=True, exclude={"products", "transport"})
  517. for key, value in update_data.items():
  518. setattr(plan, key, value)
  519. plan_type = payload.plan_type or plan.plan_type
  520. # 更新产品明细
  521. if payload.products is not None:
  522. existing_products = (
  523. db.query(PlPlanProduct)
  524. .filter(
  525. PlPlanProduct.plan_id == id,
  526. PlPlanProduct.del_flag == 0,
  527. )
  528. .all()
  529. )
  530. cert_by_product_id = {
  531. row.product_id: (row.purchase_cert_url, row.transport_cert_url)
  532. for row in existing_products
  533. }
  534. db.query(PlPlanProduct).filter(
  535. PlPlanProduct.plan_id == id,
  536. PlPlanProduct.del_flag == 0,
  537. ).update({"del_flag": 1})
  538. plan_products: list[PlPlanProduct] = []
  539. for idx, prod in enumerate(payload.products):
  540. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  541. db,
  542. tenant_no,
  543. prod.product_id,
  544. prod.is_toxic,
  545. prod.is_explosive,
  546. )
  547. purchase_cert = prod.purchase_cert_url
  548. transport_cert = prod.transport_cert_url
  549. if not purchase_cert or not transport_cert:
  550. old_purchase, old_transport = cert_by_product_id.get(prod.product_id, (None, None))
  551. purchase_cert = purchase_cert or old_purchase
  552. transport_cert = transport_cert or old_transport
  553. prod_payload = prod.model_copy(
  554. update={
  555. "is_toxic": is_toxic,
  556. "is_explosive": is_explosive,
  557. "purchase_cert_url": purchase_cert,
  558. "transport_cert_url": transport_cert,
  559. }
  560. )
  561. pp = build_plan_product(
  562. tenant_no=tenant_no,
  563. plan_id=id,
  564. plan_type=plan_type,
  565. prod=prod_payload,
  566. order_num=idx,
  567. user_id=user.id,
  568. dept_id=user.dept_id,
  569. )
  570. db.add(pp)
  571. plan_products.append(pp)
  572. plan.total_amount = calc_plan_total_amount(payload.products)
  573. else:
  574. plan_products = (
  575. db.query(PlPlanProduct)
  576. .filter(
  577. PlPlanProduct.plan_id == id,
  578. PlPlanProduct.del_flag == 0,
  579. )
  580. .all()
  581. )
  582. # 同步运输预选 / 关联运输单运费
  583. if payload.transport is not None:
  584. if payload.transport.vehicle_id:
  585. sync_transport_prefill_for_plan(
  586. db,
  587. tenant_no=tenant_no,
  588. plan=plan,
  589. transport_payload=payload.transport,
  590. products=plan_products,
  591. user_id=user.id,
  592. dept_id=user.dept_id,
  593. )
  594. else:
  595. clear_draft_transport_for_plan(
  596. db,
  597. tenant_no=tenant_no,
  598. plan=plan,
  599. user_id=user.id,
  600. )
  601. plan.update_by = user.id
  602. db.commit()
  603. db.refresh(plan)
  604. return plan
  605. @router.patch("/{id}/product-certs", response_model=PlanWithProducts)
  606. def patch_plan_product_certs(
  607. id: str,
  608. payload: PlanProductCertsUpdate,
  609. db: Session = Depends(get_db),
  610. user: DbUserContext = Depends(get_db_current_user),
  611. ) -> PlanWithProducts:
  612. """更新计划危化品产品购买证/运输证(不触发安排运输)"""
  613. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  614. _get_plan(db, tenant_no, id, user)
  615. update_plan_product_certs(
  616. db,
  617. tenant_no=tenant_no,
  618. plan_id=id,
  619. items=payload.products,
  620. user_id=user.id,
  621. )
  622. plan = _get_plan(db, tenant_no, id, user)
  623. products = (
  624. db.query(PlPlanProduct)
  625. .filter(
  626. PlPlanProduct.plan_id == id,
  627. PlPlanProduct.del_flag == 0,
  628. )
  629. .order_by(PlPlanProduct.order_num)
  630. .all()
  631. )
  632. result = PlanWithProducts.model_validate(plan)
  633. result.products = products
  634. return result
  635. def _build_driver_url(token: str) -> str:
  636. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  637. return f"{origin}/#/public/pl/transport/{token}"
  638. @router.get("/{id}/arrange-info", response_model=PlanArrangeInfoRead)
  639. def get_plan_arrange_info(
  640. id: str,
  641. db: Session = Depends(get_db),
  642. user: DbUserContext = Depends(get_db_current_user),
  643. ) -> PlanArrangeInfoRead:
  644. """获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。"""
  645. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  646. plan = _get_plan(db, tenant_no, id, user)
  647. empty = PlanArrangeInfoRead(driver_url=None, license_plate=None, site_records=[], inventory_records=[])
  648. if plan.follow_status == "cancelled" or plan.transport_status != "arranged" or not plan.transport_id:
  649. return empty
  650. transport = (
  651. db.query(PlTransport)
  652. .filter(
  653. PlTransport.id == plan.transport_id,
  654. PlTransport.tenant_no == tenant_no,
  655. PlTransport.del_flag == 0,
  656. )
  657. .first()
  658. )
  659. if not transport or transport.transport_status == "cancelled":
  660. return empty
  661. token = (transport.driver_access_token or "").strip()
  662. driver_url = _build_driver_url(token) if token else None
  663. site_rows = (
  664. db.query(PlTransportSiteLink)
  665. .filter(
  666. PlTransportSiteLink.tenant_no == tenant_no,
  667. PlTransportSiteLink.plan_id == id,
  668. PlTransportSiteLink.transport_id == transport.id,
  669. PlTransportSiteLink.del_flag == 0,
  670. )
  671. .order_by(PlTransportSiteLink.create_time.asc())
  672. .all()
  673. )
  674. product_name_by_id = {
  675. item.id: item.product_name or ""
  676. for item in db.query(PlPlanProduct)
  677. .filter(
  678. PlPlanProduct.plan_id == id,
  679. PlPlanProduct.del_flag == 0,
  680. )
  681. .all()
  682. }
  683. site_records: list[TransportSiteLinkRead] = [
  684. build_transport_site_link_read_from_row(
  685. row,
  686. product_name=product_name_by_id.get(row.plan_product_id, row.product or ""),
  687. )
  688. for row in site_rows
  689. ]
  690. links = (
  691. db.query(PlTransportDbLink)
  692. .filter(
  693. PlTransportDbLink.tenant_no == tenant_no,
  694. PlTransportDbLink.plan_id == id,
  695. PlTransportDbLink.transport_id == transport.id,
  696. PlTransportDbLink.del_flag == 0,
  697. )
  698. .all()
  699. )
  700. inventory_records: list[TransportInventoryLinkRead] = []
  701. for link in links:
  702. product = (
  703. db.query(PlPlanProduct)
  704. .filter(
  705. PlPlanProduct.id == link.plan_product_id,
  706. PlPlanProduct.del_flag == 0,
  707. )
  708. .first()
  709. )
  710. inventory = (
  711. db.query(DbInboundOutbound)
  712. .filter(
  713. DbInboundOutbound.id == link.db_inventory_id,
  714. DbInboundOutbound.tenant_no == tenant_no,
  715. DbInboundOutbound.del_flag == 0,
  716. )
  717. .first()
  718. )
  719. inventory_records.append(
  720. build_transport_inventory_link_read(
  721. db,
  722. plan_product_id=link.plan_product_id,
  723. product_name=(product.product_name if product else "") or "",
  724. db_purchase_plan_id=link.db_purchase_plan_id,
  725. db_inventory_id=link.db_inventory_id,
  726. batch_no=(inventory.batch_no if inventory else "") or "",
  727. plan_no=plan.plan_no or "",
  728. )
  729. )
  730. return PlanArrangeInfoRead(
  731. driver_url=driver_url,
  732. license_plate=transport.license_plate or plan.license_plate,
  733. site_records=site_records,
  734. inventory_records=inventory_records,
  735. )
  736. # =============================================================================
  737. # 取消计划
  738. # =============================================================================
  739. @router.post("/{id}/cancel", response_model=PlanRead)
  740. def cancel_plan(
  741. id: str,
  742. db: Session = Depends(get_db),
  743. user: DbUserContext = Depends(get_db_current_user),
  744. ) -> PlanRead:
  745. """作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。"""
  746. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  747. plan = _get_plan(db, tenant_no, id, user)
  748. if plan.follow_status == "cancelled":
  749. raise HTTPException(status_code=400, detail="计划已作废")
  750. if plan.archive_status == "archived":
  751. raise HTTPException(status_code=400, detail="已归档的计划不能作废")
  752. if plan.transport_id:
  753. transport = (
  754. db.query(PlTransport)
  755. .filter(
  756. PlTransport.id == plan.transport_id,
  757. PlTransport.tenant_no == tenant_no,
  758. PlTransport.del_flag == 0,
  759. )
  760. .first()
  761. )
  762. if transport:
  763. try:
  764. revoke_regeneratable_dibang_for_transport(
  765. db,
  766. tenant_no=tenant_no,
  767. transport_id=transport.id,
  768. user_id=user.id,
  769. )
  770. except ValueError as exc:
  771. raise HTTPException(status_code=400, detail=str(exc)) from exc
  772. revoke_site_links_for_transport(
  773. db,
  774. tenant_no=tenant_no,
  775. transport_id=transport.id,
  776. user_id=user.id,
  777. )
  778. transport.transport_status = "cancelled"
  779. transport.update_by = user.id
  780. plan.follow_status = "cancelled"
  781. plan.transport_status = "pending"
  782. plan.update_by = user.id
  783. db.commit()
  784. db.refresh(plan)
  785. return plan
  786. # =============================================================================
  787. # 归档计划
  788. # =============================================================================
  789. @router.post("/{id}/archive", response_model=PlanWithProducts)
  790. def archive_plan(
  791. id: str,
  792. payload: Optional[PlanArchiveBody] = Body(default=None),
  793. db: Session = Depends(get_db),
  794. user: DbUserContext = Depends(get_db_current_user),
  795. ) -> PlanWithProducts:
  796. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  797. plan = _get_plan(db, tenant_no, id, user)
  798. if plan.follow_status == "cancelled":
  799. raise HTTPException(status_code=400, detail="已作废的计划不能归档")
  800. already_archived = plan.archive_status == "archived"
  801. if payload is None:
  802. if already_archived:
  803. raise HTTPException(status_code=400, detail="计划已归档,请提交归档表单以调整内容")
  804. plan.archive_status = "archived"
  805. plan.complete_status = "yes"
  806. plan.follow_status = "completed"
  807. plan.update_by = user.id
  808. db.commit()
  809. return get_plan(id, db, user)
  810. apply_archive_product_quantities(
  811. db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
  812. )
  813. if payload.freight is not None:
  814. apply_archive_freight(
  815. db, tenant_no=tenant_no, plan=plan, freight=payload.freight, user_id=user.id
  816. )
  817. elif int(plan.has_freight or 0) == 1 and not already_archived:
  818. if not plan.transport_id:
  819. raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
  820. if not already_archived:
  821. plan.archive_status = "archived"
  822. plan.complete_status = "yes"
  823. plan.follow_status = "completed"
  824. plan.update_by = user.id
  825. record_plan_change(
  826. db,
  827. tenant_no=tenant_no,
  828. plan_id=plan.id,
  829. change_type="archive_adjust" if already_archived else "archive",
  830. change_content="调整归档内容" if already_archived else "运输计划归档",
  831. user_id=user.id,
  832. user_name=user.name or None,
  833. dept_id=user.dept_id,
  834. )
  835. db.commit()
  836. return get_plan(id, db, user)
  837. # =============================================================================
  838. # 删除计划
  839. # =============================================================================
  840. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  841. def delete_plan(
  842. id: str,
  843. db: Session = Depends(get_db),
  844. user: DbUserContext = Depends(get_db_current_user),
  845. ) -> None:
  846. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  847. plan = _get_plan(db, tenant_no, id, user)
  848. plan.del_flag = 1
  849. plan.update_by = user.id
  850. db.query(PlPlanProduct).filter(
  851. PlPlanProduct.plan_id == id,
  852. PlPlanProduct.tenant_no == tenant_no,
  853. PlPlanProduct.del_flag == 0,
  854. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  855. db.query(PlPlanTransport).filter(
  856. PlPlanTransport.plan_id == id,
  857. PlPlanTransport.tenant_no == tenant_no,
  858. PlPlanTransport.del_flag == 0,
  859. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  860. db.commit()
  861. # =============================================================================
  862. # 辅助函数
  863. # =============================================================================
  864. def _get_plan(db: Session, tenant_no: str, plan_id: str, user: DbUserContext) -> PlPlan:
  865. plan = (
  866. db.query(PlPlan)
  867. .filter(
  868. PlPlan.id == plan_id,
  869. PlPlan.tenant_no == tenant_no,
  870. PlPlan.del_flag == 0,
  871. )
  872. .first()
  873. )
  874. if not plan:
  875. raise HTTPException(status_code=404, detail="计划不存在")
  876. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  877. return plan