plan.py 49 KB

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