plan.py 49 KB

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