plan.py 35 KB

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