plan.py 47 KB

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