plan.py 55 KB

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