plan.py 49 KB

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