plan.py 53 KB

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