plan.py 52 KB

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