plan.py 80 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248
  1. """运输计划管理 - 计划 API"""
  2. import re
  3. from datetime import date, datetime
  4. from typing import List, Optional
  5. from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
  6. from pydantic import BaseModel, Field
  7. from sqlalchemy import func, or_
  8. from sqlalchemy.orm import Session
  9. from app.core.config import settings
  10. from app.core.database import get_db
  11. from app.core.db_auth import DbUserContext, get_db_current_user
  12. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  13. from app.core.menu_guard import SUPER_ADMIN_ROLE, get_user_menu_codes
  14. from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
  15. from app.services.pl_plan_arrange_copy_flags import load_plan_arrange_copy_flags
  16. from app.core.tenant_context import get_tenant_no
  17. from app.utils.excel_export import (
  18. EXPORT_MAX_ROWS,
  19. ExportColumn,
  20. ExportValidationError,
  21. export_rows_to_xlsx_response,
  22. filter_export_columns,
  23. )
  24. from app.core.utils import generate_uuid
  25. from app.models.crm_models import CrmContract, CrmContractProduct, CrmProduct
  26. from app.models.db_models import DbInboundOutbound
  27. from app.models.pl_models import (
  28. PlCarrier,
  29. PlDriver,
  30. PlPlan,
  31. PlPlanProduct,
  32. PlPlanTransport,
  33. PlTransport,
  34. PlTransportDbLink,
  35. PlTransportSiteLink,
  36. PlVehicle,
  37. )
  38. from app.schemas.pl import PlanProductCreate
  39. from app.schemas.pl.plan import (
  40. CreateFromContractRequest,
  41. PlanArchiveBody,
  42. PlanArchiveBrandQtyItem,
  43. PlanContractGapListResponse,
  44. PlanContractGapRead,
  45. PlanCreate,
  46. PlanCreateFull,
  47. PlanDriverFeedbackUpdateBody,
  48. HazardCertReadyBody,
  49. ErpEnteredBody,
  50. PlanForceFollowStatusBody,
  51. PlanInboundSlipTemplateSave,
  52. PlanDeliverySlipTemplateSave,
  53. PlanSubpackSlipTemplateSave,
  54. PlanListQuery,
  55. PlanListResponse,
  56. PlanProductCertsUpdate,
  57. PlanProductRead,
  58. PlanRead,
  59. PlanSideContractsUpdateBody,
  60. PlanSiteFeedbackUpdateBody,
  61. PlanUpdate,
  62. PlanWithProducts,
  63. )
  64. from app.services.pl_plan_contract_gap import list_plan_contract_gaps, update_plan_side_contracts
  65. from app.services.pl_brand_archive import load_brand_qtys_by_plan_product_ids
  66. from app.services.pl_product_display import (
  67. load_crm_product_alias_maps,
  68. resolve_product_display_names,
  69. )
  70. from app.schemas.pl.transport import (
  71. PlanArrangeInfoRead,
  72. TransportInventoryLinkRead,
  73. TransportIoOrderBrief,
  74. TransportSiteLinkRead,
  75. )
  76. from app.services.pl_dibang_bridge_service import revoke_regeneratable_dibang_for_transport
  77. from app.services.pl_inventory_link_read import build_transport_inventory_link_read
  78. from app.services.pl_io_order_service import get_active_io_order_for_plan, void_io_orders_for_plan
  79. from app.services.pl_site_link_read import build_transport_site_link_read_from_row
  80. from app.services.pl_transport_site_service import revoke_site_links_for_transport
  81. from app.services.pl_inventory_service import apply_archive_inventory_for_plan
  82. from app.services.pl_plan_list_attachments import load_plan_document_attachments_by_plan_ids
  83. from app.services.pl_plan_service import (
  84. apply_archive_freight,
  85. apply_archive_product_quantities,
  86. apply_plan_driver_feedback,
  87. apply_plan_site_feedback,
  88. apply_vehicle_prefill_to_plan,
  89. assert_contract_selectable_for_plan,
  90. build_plan_product,
  91. calc_plan_total_amount,
  92. clear_draft_transport_for_plan,
  93. clear_hazard_cert_ready,
  94. collect_hazard_product_ids,
  95. determine_plan_type,
  96. fetch_hazard_flags_map,
  97. load_plan_contracts_grouped,
  98. plan_has_hazard_product_exists,
  99. plan_has_incomplete_hazard_cert_exists,
  100. record_plan_change,
  101. replace_plan_contracts,
  102. resolve_hazard_flags_from_crm,
  103. set_hazard_cert_ready,
  104. set_plan_erp_entered,
  105. sync_transport_prefill_for_plan,
  106. update_plan_product_certs,
  107. validate_create_full,
  108. validate_hazard_cert_ready_for_transport,
  109. validate_plan_party_contracts,
  110. )
  111. router = APIRouter(prefix="/plan", tags=["运输计划-计划管理"])
  112. _PLAN_NO_SEQ_WIDTH = 3
  113. def _resolve_list_product_name(
  114. product: PlPlanProduct,
  115. *,
  116. standard_by_id: dict[str, str],
  117. alias_options_by_id: dict[str, list[str]],
  118. ) -> str:
  119. """列表展示用:优先 CRM 标准名(详情仍返回库内 product_name,以便编辑反推别名)。"""
  120. display_name, _alias = resolve_product_display_names(
  121. saved_product_name=product.product_name,
  122. product_id=product.product_id,
  123. standard_by_id=standard_by_id,
  124. alias_options_by_id=alias_options_by_id,
  125. )
  126. return display_name or (product.product_name or "")
  127. def _resolve_create_date(value: Optional[date]) -> date:
  128. return value or date.today()
  129. def _generate_plan_no(db: Session) -> str:
  130. """生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)"""
  131. today = date.today()
  132. prefix = f"PO{today.strftime('%Y%m%d')}"
  133. # uk_plan_no 对全表生效(含软删),取号时不能过滤 del_flag
  134. rows = (
  135. db.query(PlPlan.plan_no)
  136. .filter(PlPlan.plan_no.like(f"{prefix}%"))
  137. .all()
  138. )
  139. max_seq = 0
  140. for (plan_no,) in rows:
  141. suffix = (plan_no or "")[len(prefix) :]
  142. if suffix.isdigit():
  143. max_seq = max(max_seq, int(suffix))
  144. seq = max_seq + 1
  145. return f"{prefix}{seq:0{_PLAN_NO_SEQ_WIDTH}d}"
  146. _SUBPACK_SLIP_NO_SEQ_WIDTH = 3
  147. _SUBPACK_SLIP_NO_CANONICAL_PATTERN = re.compile(r"^NO:SXFZ(\d{8})-(\d+)$")
  148. _SUBPACK_SLIP_NO_BARE_PATTERN = re.compile(r"^SXFZ(\d{8})-(\d+)$")
  149. _SUBPACK_SLIP_NO_LEGACY_PATTERNS = (
  150. re.compile(r"^NO:SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
  151. re.compile(r"^SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
  152. )
  153. def _format_subpack_slip_no(day: str, seq: int) -> str:
  154. return f"NO:SXFZ{day}-{int(seq):0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
  155. def _normalize_subpack_slip_no(slip_no: str) -> Optional[str]:
  156. """将历史分装单号统一为 NO:SXFZYYYYMMDD-###"""
  157. raw = (slip_no or "").strip()
  158. if not raw:
  159. return None
  160. matched = _SUBPACK_SLIP_NO_CANONICAL_PATTERN.match(raw)
  161. if matched:
  162. day, seq = matched.groups()
  163. return _format_subpack_slip_no(day, int(seq))
  164. matched = _SUBPACK_SLIP_NO_BARE_PATTERN.match(raw)
  165. if matched:
  166. day, seq = matched.groups()
  167. return _format_subpack_slip_no(day, int(seq))
  168. for pattern in _SUBPACK_SLIP_NO_LEGACY_PATTERNS:
  169. matched = pattern.match(raw)
  170. if matched:
  171. year, month, day, seq = matched.groups()
  172. return _format_subpack_slip_no(f"{year}{month}{day}", int(seq))
  173. return None
  174. def _subpack_slip_no_seq(slip_no: str, day_prefix: str) -> int:
  175. normalized = _normalize_subpack_slip_no(slip_no)
  176. if not normalized or not normalized.startswith(day_prefix):
  177. return 0
  178. suffix = normalized[len(day_prefix) :]
  179. return int(suffix) if suffix.isdigit() else 0
  180. def _ensure_subpack_slip_no(db: Session, tenant_no: str, plan: PlPlan) -> str:
  181. """分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)"""
  182. existing = (plan.subpack_slip_no or "").strip()
  183. if existing:
  184. normalized = _normalize_subpack_slip_no(existing)
  185. if normalized:
  186. if normalized != existing:
  187. plan.subpack_slip_no = normalized
  188. return normalized
  189. today = date.today()
  190. prefix = f"NO:SXFZ{today.strftime('%Y%m%d')}-"
  191. rows = (
  192. db.query(PlPlan.subpack_slip_no)
  193. .filter(
  194. PlPlan.tenant_no == tenant_no,
  195. PlPlan.subpack_slip_no.isnot(None),
  196. PlPlan.subpack_slip_no != "",
  197. )
  198. .all()
  199. )
  200. max_seq = 0
  201. for (slip_no,) in rows:
  202. max_seq = max(max_seq, _subpack_slip_no_seq(slip_no or "", prefix))
  203. next_no = f"{prefix}{max_seq + 1:0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
  204. plan.subpack_slip_no = next_no
  205. return next_no
  206. def _determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
  207. """根据供应商和目的地自动判断单据类型"""
  208. return determine_plan_type(supplier_name, destination_name)
  209. def _get_plan_type_label(plan_type: str) -> str:
  210. labels = {
  211. "direct": "直销单",
  212. "inbound": "入库单",
  213. "outbound": "出库单",
  214. }
  215. return labels.get(plan_type, plan_type)
  216. def _generate_notify_message(plan: PlPlan, products: list) -> str:
  217. """生成通知信息"""
  218. product_names = "、".join([p.product_name for p in products[:3]])
  219. if len(products) > 3:
  220. product_names += "等"
  221. return f"""计划号:{plan.plan_no}
  222. 企业名称:{plan.customer_name or ''}
  223. 提货单位:{plan.destination_name or ''}
  224. 到货时间:{plan.contract_end_date or ''}
  225. 药品名称:{product_names}
  226. 到货吨位:{sum(float(p.inbound_estimate_qty or p.outbound_estimate_qty or 0) for p in products)}吨
  227. 备注:"""
  228. # =============================================================================
  229. # 计划列表
  230. # =============================================================================
  231. def _plan_product_name_exists(db: Session, tenant_no: str, keyword: str):
  232. """子查询:计划产品名称模糊匹配。"""
  233. return (
  234. db.query(PlPlanProduct.id)
  235. .filter(
  236. PlPlanProduct.plan_id == PlPlan.id,
  237. PlPlanProduct.tenant_no == tenant_no,
  238. PlPlanProduct.del_flag == 0,
  239. func.lower(PlPlanProduct.product_name).contains(keyword.lower()),
  240. )
  241. .exists()
  242. )
  243. def _plan_plate_match(keyword: str):
  244. """车牌号 / 挂车号模糊匹配。"""
  245. kw = keyword.lower()
  246. return or_(
  247. func.lower(PlPlan.license_plate).contains(kw),
  248. func.lower(PlPlan.trailer_no).contains(kw),
  249. )
  250. @router.get("/", response_model=PlanListResponse)
  251. def list_plans(
  252. q: Optional[str] = Query(
  253. default=None,
  254. description="模糊搜索(计划编号/供方名称/需方名称/产品名称/车牌号)",
  255. ),
  256. plan_no: Optional[str] = Query(default=None, description="计划编号"),
  257. plan_type: Optional[str] = Query(default=None, alias="planType", description="单据类型"),
  258. follow_status: Optional[str] = Query(default=None, alias="followStatus", description="跟进状态"),
  259. transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
  260. archive_status: Optional[str] = Query(default=None, alias="archiveStatus", description="归档状态"),
  261. customer_name: Optional[str] = Query(default=None, alias="customerName", description="客户名称"),
  262. product_name: Optional[str] = Query(default=None, alias="productName", description="产品名称"),
  263. license_plate: Optional[str] = Query(default=None, alias="licensePlate", description="车牌号"),
  264. plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom", description="计划日期开始"),
  265. plan_date_to: Optional[date] = Query(default=None, alias="planDateTo", description="计划日期结束"),
  266. has_hazard_product: Optional[int] = Query(
  267. default=None,
  268. alias="hasHazardProduct",
  269. description="1=仅含易制毒/易制爆产品的计划",
  270. ),
  271. cert_upload_status: Optional[str] = Query(
  272. default=None,
  273. alias="certUploadStatus",
  274. description="incomplete=至少一张必需危化证书未上传",
  275. ),
  276. page: int = Query(default=1, ge=1),
  277. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  278. db: Session = Depends(get_db),
  279. user: DbUserContext = Depends(get_db_current_user),
  280. ) -> PlanListResponse:
  281. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  282. query = db.query(PlPlan).filter(
  283. PlPlan.tenant_no == tenant_no,
  284. PlPlan.del_flag == 0,
  285. )
  286. if q:
  287. q_kw = q.strip()
  288. if q_kw:
  289. q_lower = q_kw.lower()
  290. query = query.filter(
  291. or_(
  292. PlPlan.plan_no.contains(q_kw),
  293. func.lower(PlPlan.supplier_name).contains(q_lower),
  294. func.lower(PlPlan.customer_name).contains(q_lower),
  295. func.lower(PlPlan.destination_name).contains(q_lower),
  296. _plan_product_name_exists(db, tenant_no, q_kw),
  297. _plan_plate_match(q_kw),
  298. )
  299. )
  300. if plan_no:
  301. query = query.filter(PlPlan.plan_no.contains(plan_no))
  302. if plan_type:
  303. query = query.filter(PlPlan.plan_type == plan_type)
  304. if follow_status:
  305. query = query.filter(PlPlan.follow_status == follow_status)
  306. if transport_status:
  307. query = query.filter(PlPlan.transport_status == transport_status)
  308. if archive_status:
  309. query = query.filter(PlPlan.archive_status == archive_status)
  310. if customer_name:
  311. query = query.filter(func.lower(PlPlan.customer_name).contains(customer_name.lower()))
  312. if product_name:
  313. product_name_kw = product_name.strip()
  314. if product_name_kw:
  315. query = query.filter(_plan_product_name_exists(db, tenant_no, product_name_kw))
  316. if license_plate:
  317. plate_kw = license_plate.strip()
  318. if plate_kw:
  319. query = query.filter(_plan_plate_match(plate_kw))
  320. if plan_date_from:
  321. query = query.filter(PlPlan.plan_date >= plan_date_from)
  322. if plan_date_to:
  323. query = query.filter(PlPlan.plan_date <= plan_date_to)
  324. if has_hazard_product == 1:
  325. query = query.filter(plan_has_hazard_product_exists(tenant_no))
  326. if (cert_upload_status or "").strip().lower() == "incomplete":
  327. query = query.filter(plan_has_incomplete_hazard_cert_exists(tenant_no))
  328. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  329. # 统计总数
  330. total = query.count()
  331. # 分页
  332. offset = (page - 1) * page_size
  333. plans = query.order_by(PlPlan.create_time.desc()).offset(offset).limit(page_size).all()
  334. plan_ids = [plan.id for plan in plans]
  335. products_by_plan: dict[str, list[PlPlanProduct]] = {}
  336. all_products: list[PlPlanProduct] = []
  337. if plan_ids:
  338. all_products = (
  339. db.query(PlPlanProduct)
  340. .filter(
  341. PlPlanProduct.plan_id.in_(plan_ids),
  342. PlPlanProduct.del_flag == 0,
  343. )
  344. .order_by(PlPlanProduct.order_num)
  345. .all()
  346. )
  347. for product in all_products:
  348. products_by_plan.setdefault(product.plan_id, []).append(product)
  349. product_ids = [product.product_id for product in all_products if product.product_id]
  350. hazard_map = fetch_hazard_flags_map(
  351. db,
  352. tenant_no,
  353. product_ids,
  354. )
  355. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  356. db,
  357. tenant_no=tenant_no,
  358. product_ids=product_ids,
  359. )
  360. brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
  361. db,
  362. tenant_no=tenant_no,
  363. plan_product_ids=[p.id for p in all_products],
  364. )
  365. contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
  366. arrange_copy_flags = load_plan_arrange_copy_flags(db, tenant_no, plans)
  367. document_attachments_by_plan = load_plan_document_attachments_by_plan_ids(
  368. db,
  369. tenant_no=tenant_no,
  370. plan_ids=plan_ids,
  371. )
  372. result_list: list[PlanWithProducts] = []
  373. for plan in plans:
  374. enriched_products: list[PlanProductRead] = []
  375. for product in products_by_plan.get(plan.id, []):
  376. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  377. db,
  378. tenant_no,
  379. product.product_id,
  380. hazard_map=hazard_map,
  381. )
  382. item = PlanProductRead.model_validate(product)
  383. raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
  384. brand_qtys = (
  385. [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
  386. if raw_brand_qtys
  387. else None
  388. )
  389. item = item.model_copy(
  390. update={
  391. "is_toxic": is_toxic,
  392. "is_explosive": is_explosive,
  393. "brand_qtys": brand_qtys,
  394. "product_name": _resolve_list_product_name(
  395. product,
  396. standard_by_id=standard_by_id,
  397. alias_options_by_id=alias_options_by_id,
  398. ),
  399. }
  400. )
  401. enriched_products.append(item)
  402. plan_item = PlanWithProducts.model_validate(plan)
  403. plan_item.products = enriched_products
  404. supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
  405. plan_item.supplier_contracts = supplier_contracts
  406. plan_item.demander_contracts = demander_contracts
  407. flags = arrange_copy_flags.get(plan.id)
  408. if flags:
  409. plan_item.can_copy_to_driver = flags.can_copy_to_driver
  410. plan_item.can_copy_to_site = flags.can_copy_to_site
  411. plan_item.can_copy_pound_qrcode = flags.can_copy_pound_qrcode
  412. if not (plan_item.personnel_summary or "").strip():
  413. snapshot = plan_item.resource_snapshot
  414. if snapshot and snapshot.personnel_summary:
  415. plan_item.personnel_summary = snapshot.personnel_summary
  416. elif plan_item.driver_name:
  417. plan_item.personnel_summary = f"{plan_item.driver_name}(驾)"
  418. plan_item.document_attachments = document_attachments_by_plan.get(plan.id, [])
  419. result_list.append(plan_item)
  420. return PlanListResponse(total=total, list=result_list)
  421. PLAN_EXPORT_COLUMNS = [
  422. ExportColumn(title="计划编号", field="planNo"),
  423. ExportColumn(title="跟进状态", field="followStatus"),
  424. ExportColumn(title="计划日期", field="planDate"),
  425. ExportColumn(title="单据类型", field="planType"),
  426. ExportColumn(title="供方", field="supplierName"),
  427. ExportColumn(title="需方", field="customerName"),
  428. ExportColumn(title="车牌号", field="licensePlate"),
  429. ExportColumn(title="司机", field="driverName"),
  430. ExportColumn(title="运输状态", field="transportStatus"),
  431. ExportColumn(title="归档状态", field="archiveStatus"),
  432. ExportColumn(title="ERP", field="erpEntered"),
  433. ExportColumn(title="备注", field="remark"),
  434. ]
  435. PLAN_ARCHIVE_EXPORT_COLUMNS = [
  436. ExportColumn(title="序号", field="rowNo"),
  437. ExportColumn(title="到货日期", field="deliveryDate"),
  438. ExportColumn(title="供方名称", field="supplierName"),
  439. ExportColumn(title="需方名称", field="customerName"),
  440. ExportColumn(title="产品名称", field="productName"),
  441. ExportColumn(title="型号/规格", field="model"),
  442. ExportColumn(title="包装", field="packageSpec"),
  443. ExportColumn(title="单位", field="unit"),
  444. ExportColumn(title="入库数", field="inboundQty"),
  445. ExportColumn(title="入库价", field="inboundPrice"),
  446. ExportColumn(title="入库金额", field="inboundAmount"),
  447. ExportColumn(title="出库数", field="outboundQty"),
  448. ExportColumn(title="出库价", field="outboundPrice"),
  449. ExportColumn(title="出库金额", field="outboundAmount"),
  450. ExportColumn(title="车牌号", field="licensePlate"),
  451. ExportColumn(title="运费单位", field="freightType"),
  452. ExportColumn(title="运费单价", field="freightPrice"),
  453. ExportColumn(title="运费金额", field="freightAmount"),
  454. ExportColumn(title="备注", field="remark"),
  455. ]
  456. PLAN_ARCHIVE_FREIGHT_TYPE_LABELS = {
  457. "by_weight": "按重量",
  458. "by_trip": "按趟",
  459. }
  460. def _format_plan_delivery_date_for_export(plan: PlPlan) -> str:
  461. if plan.delivery_time is None:
  462. return ""
  463. return str(plan.delivery_time)[:10]
  464. def _load_transports_by_id(db: Session, tenant_no: str, transport_ids: list[str]) -> dict[str, PlTransport]:
  465. if not transport_ids:
  466. return {}
  467. rows = (
  468. db.query(PlTransport)
  469. .filter(
  470. PlTransport.tenant_no == tenant_no,
  471. PlTransport.del_flag == 0,
  472. PlTransport.id.in_(transport_ids),
  473. )
  474. .all()
  475. )
  476. return {row.id: row for row in rows}
  477. def _load_products_by_plan_id(db: Session, tenant_no: str, plan_ids: list[str]) -> dict[str, list[PlPlanProduct]]:
  478. if not plan_ids:
  479. return {}
  480. products = (
  481. db.query(PlPlanProduct)
  482. .filter(
  483. PlPlanProduct.tenant_no == tenant_no,
  484. PlPlanProduct.del_flag == 0,
  485. PlPlanProduct.plan_id.in_(plan_ids),
  486. )
  487. .order_by(PlPlanProduct.order_num)
  488. .all()
  489. )
  490. grouped: dict[str, list[PlPlanProduct]] = {}
  491. for product in products:
  492. grouped.setdefault(product.plan_id, []).append(product)
  493. return grouped
  494. def _build_plan_archive_export_rows(
  495. plans: list[PlPlan],
  496. products_by_plan: dict[str, list[PlPlanProduct]],
  497. transports_by_id: dict[str, PlTransport],
  498. ) -> list[dict]:
  499. rows: list[dict] = []
  500. row_no = 0
  501. for plan in plans:
  502. products = products_by_plan.get(plan.id, [])
  503. transport = transports_by_id.get(plan.transport_id) if plan.transport_id else None
  504. freight_type_raw = transport.freight_type if transport else "by_weight"
  505. freight_type_label = PLAN_ARCHIVE_FREIGHT_TYPE_LABELS.get(
  506. freight_type_raw, freight_type_raw or ""
  507. )
  508. freight_price = transport.freight_price if transport else None
  509. freight_amount = plan.freight_amount
  510. if freight_amount is None and transport is not None:
  511. freight_amount = transport.freight_amount
  512. plan_common = {
  513. "deliveryDate": _format_plan_delivery_date_for_export(plan),
  514. "supplierName": plan.supplier_name or "",
  515. "customerName": plan.customer_name or "",
  516. "licensePlate": plan.license_plate or "",
  517. "freightType": freight_type_label,
  518. "freightPrice": freight_price,
  519. "freightAmount": freight_amount,
  520. "remark": "",
  521. }
  522. product_rows = products or [None]
  523. for product in product_rows:
  524. row_no += 1
  525. row = {
  526. **plan_common,
  527. "rowNo": row_no,
  528. "productName": "",
  529. "model": "",
  530. "packageSpec": "",
  531. "unit": "",
  532. "inboundQty": None,
  533. "inboundPrice": None,
  534. "inboundAmount": None,
  535. "outboundQty": None,
  536. "outboundPrice": None,
  537. "outboundAmount": None,
  538. }
  539. if product is not None:
  540. row.update(
  541. {
  542. "productName": product.product_name or "",
  543. "model": product.model or "",
  544. "packageSpec": product.package_spec or "",
  545. "unit": product.unit or "",
  546. "inboundQty": product.inbound_qty,
  547. "inboundPrice": product.inbound_price,
  548. "inboundAmount": product.inbound_amount,
  549. "outboundQty": product.outbound_qty,
  550. "outboundPrice": product.outbound_price,
  551. "outboundAmount": product.outbound_amount,
  552. }
  553. )
  554. rows.append(row)
  555. return rows
  556. class PlanExportBody(BaseModel):
  557. q: Optional[str] = None
  558. plan_no: Optional[str] = Field(default=None, alias="planNo")
  559. plan_type: Optional[str] = Field(default=None, alias="planType")
  560. follow_status: Optional[str] = Field(default=None, alias="followStatus")
  561. transport_status: Optional[str] = Field(default=None, alias="transportStatus")
  562. archive_status: Optional[str] = Field(default=None, alias="archiveStatus")
  563. customer_name: Optional[str] = Field(default=None, alias="customerName")
  564. product_name: Optional[str] = Field(default=None, alias="productName")
  565. license_plate: Optional[str] = Field(default=None, alias="licensePlate")
  566. plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom")
  567. plan_date_to: Optional[date] = Field(default=None, alias="planDateTo")
  568. has_hazard_product: Optional[int] = Field(default=None, alias="hasHazardProduct")
  569. cert_upload_status: Optional[str] = Field(default=None, alias="certUploadStatus")
  570. ids: Optional[List[str]] = None
  571. columns: Optional[List[str]] = None
  572. export_format: Optional[str] = Field(default=None, alias="exportFormat", description="archive=运输计划归档专用导出")
  573. model_config = {"populate_by_name": True}
  574. @router.post("/export")
  575. def export_plans(
  576. body: PlanExportBody,
  577. db: Session = Depends(get_db),
  578. user: DbUserContext = Depends(get_db_current_user),
  579. ):
  580. """导出计划列表(当前筛选全量,或 ids 已选)。"""
  581. try:
  582. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  583. query = db.query(PlPlan).filter(
  584. PlPlan.tenant_no == tenant_no,
  585. PlPlan.del_flag == 0,
  586. )
  587. if body.q:
  588. q_kw = body.q.strip()
  589. if q_kw:
  590. q_lower = q_kw.lower()
  591. query = query.filter(
  592. or_(
  593. PlPlan.plan_no.contains(q_kw),
  594. func.lower(PlPlan.supplier_name).contains(q_lower),
  595. func.lower(PlPlan.customer_name).contains(q_lower),
  596. func.lower(PlPlan.destination_name).contains(q_lower),
  597. _plan_product_name_exists(db, tenant_no, q_kw),
  598. _plan_plate_match(q_kw),
  599. )
  600. )
  601. if body.plan_no:
  602. query = query.filter(PlPlan.plan_no.contains(body.plan_no))
  603. if body.plan_type:
  604. query = query.filter(PlPlan.plan_type == body.plan_type)
  605. if body.follow_status:
  606. query = query.filter(PlPlan.follow_status == body.follow_status)
  607. if body.transport_status:
  608. query = query.filter(PlPlan.transport_status == body.transport_status)
  609. if body.archive_status:
  610. query = query.filter(PlPlan.archive_status == body.archive_status)
  611. if body.customer_name:
  612. query = query.filter(func.lower(PlPlan.customer_name).contains(body.customer_name.lower()))
  613. if body.product_name:
  614. product_name_kw = body.product_name.strip()
  615. if product_name_kw:
  616. query = query.filter(_plan_product_name_exists(db, tenant_no, product_name_kw))
  617. if body.license_plate:
  618. plate_kw = body.license_plate.strip()
  619. if plate_kw:
  620. query = query.filter(_plan_plate_match(plate_kw))
  621. if body.plan_date_from:
  622. query = query.filter(PlPlan.plan_date >= body.plan_date_from)
  623. if body.plan_date_to:
  624. query = query.filter(PlPlan.plan_date <= body.plan_date_to)
  625. if body.has_hazard_product == 1:
  626. query = query.filter(plan_has_hazard_product_exists(tenant_no))
  627. if (body.cert_upload_status or "").strip().lower() == "incomplete":
  628. query = query.filter(plan_has_incomplete_hazard_cert_exists(tenant_no))
  629. if body.ids:
  630. query = query.filter(PlPlan.id.in_(body.ids))
  631. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  632. plans = query.order_by(PlPlan.create_time.desc()).limit(EXPORT_MAX_ROWS + 1).all()
  633. date_str = datetime.now().strftime("%Y%m%d")
  634. is_archive_export = (body.export_format or "").strip().lower() == "archive"
  635. if is_archive_export:
  636. plan_ids = [plan.id for plan in plans]
  637. products_by_plan = _load_products_by_plan_id(db, tenant_no, plan_ids)
  638. transport_ids = [plan.transport_id for plan in plans if plan.transport_id]
  639. transports_by_id = _load_transports_by_id(db, tenant_no, transport_ids)
  640. items = _build_plan_archive_export_rows(plans, products_by_plan, transports_by_id)
  641. cols = PLAN_ARCHIVE_EXPORT_COLUMNS
  642. sheet_title = "计划归档"
  643. filename_stem = f"计划归档_{date_str}"
  644. else:
  645. items = []
  646. for plan in plans:
  647. data = PlanWithProducts.model_validate(plan).model_dump(by_alias=True)
  648. if data.get("planDate") is not None:
  649. data["planDate"] = str(data["planDate"])[:10]
  650. if data.get("erpEntered") is not None:
  651. data["erpEntered"] = "是" if int(data["erpEntered"]) else "否"
  652. items.append(data)
  653. cols = filter_export_columns(PLAN_EXPORT_COLUMNS, body.columns)
  654. sheet_title = "计划列表"
  655. filename_stem = f"计划列表_{date_str}"
  656. return export_rows_to_xlsx_response(
  657. db,
  658. tenant_no=tenant_no,
  659. sheet_title=sheet_title,
  660. columns=cols,
  661. rows=items,
  662. filename_stem=filename_stem,
  663. )
  664. except ExportValidationError as e:
  665. raise HTTPException(status_code=400, detail=str(e)) from e
  666. @router.get("/count-by-contract")
  667. def count_plans_by_contract(
  668. contract_ids: str = Query(..., alias="contractIds", description="合同ID,逗号分隔"),
  669. db: Session = Depends(get_db),
  670. user: DbUserContext = Depends(get_db_current_user),
  671. ) -> dict:
  672. from app.models.pl_models import PlPlanContract
  673. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  674. ids = [item.strip() for item in contract_ids.split(",") if item.strip()]
  675. if not ids:
  676. return {"counts": {}}
  677. # 旧单:主表 contract_id;新单:pl_plan_contract 分侧关联(按计划去重计数)
  678. legacy_rows = (
  679. db.query(PlPlan.contract_id, func.count(PlPlan.id))
  680. .filter(
  681. PlPlan.tenant_no == tenant_no,
  682. PlPlan.del_flag == 0,
  683. PlPlan.contract_id.in_(ids),
  684. )
  685. )
  686. legacy_rows = apply_pl_scope(legacy_rows, PlPlan, user, db, tenant_no).group_by(PlPlan.contract_id).all()
  687. counts: dict[str, int] = {contract_id: count for contract_id, count in legacy_rows}
  688. link_q = (
  689. db.query(PlPlanContract.contract_id, PlPlan.id)
  690. .join(PlPlan, PlPlan.id == PlPlanContract.plan_id)
  691. .filter(
  692. PlPlanContract.tenant_no == tenant_no,
  693. PlPlanContract.del_flag == 0,
  694. PlPlanContract.contract_id.in_(ids),
  695. PlPlan.tenant_no == tenant_no,
  696. PlPlan.del_flag == 0,
  697. )
  698. )
  699. link_q = apply_pl_scope(link_q, PlPlan, user, db, tenant_no)
  700. seen: set[tuple[str, str]] = set()
  701. for contract_id, plan_id in link_q.all():
  702. key = (contract_id, plan_id)
  703. if key in seen:
  704. continue
  705. seen.add(key)
  706. # 若旧主表已计过同一计划+合同,不再重复加(新单主表 contract_id 应为空)
  707. counts[contract_id] = counts.get(contract_id, 0) + 1
  708. return {"counts": counts}
  709. @router.get("/contract-gaps", response_model=PlanContractGapListResponse)
  710. def list_contract_gaps(
  711. page: int = Query(default=1, ge=1),
  712. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  713. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划编号"),
  714. gap_type: Optional[str] = Query(
  715. default=None,
  716. alias="gapType",
  717. description="缺口类型:supplier | demander | both",
  718. ),
  719. customer_keyword: Optional[str] = Query(
  720. default=None,
  721. alias="customerKeyword",
  722. description="供方或需方名称关键词",
  723. ),
  724. plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom"),
  725. plan_date_to: Optional[date] = Query(default=None, alias="planDateTo"),
  726. q: Optional[str] = Query(
  727. default=None,
  728. description="模糊搜索(计划编号/供方名称/需方名称)",
  729. ),
  730. db: Session = Depends(get_db),
  731. user: DbUserContext = Depends(get_db_current_user),
  732. ) -> PlanContractGapListResponse:
  733. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  734. total, items = list_plan_contract_gaps(
  735. db,
  736. tenant_no=tenant_no,
  737. user=user,
  738. page=page,
  739. page_size=page_size,
  740. plan_no=plan_no,
  741. gap_type=gap_type,
  742. customer_keyword=customer_keyword,
  743. plan_date_from=plan_date_from,
  744. plan_date_to=plan_date_to,
  745. q=q,
  746. )
  747. return PlanContractGapListResponse(total=total, list=items)
  748. CONTRACT_GAP_EXPORT_COLUMNS = [
  749. ExportColumn(title="计划编号", field="planNo"),
  750. ExportColumn(title="计划日期", field="planDate"),
  751. ExportColumn(title="单据类型", field="planType"),
  752. ExportColumn(title="跟进状态", field="followStatus"),
  753. ExportColumn(title="供方", field="supplierName"),
  754. ExportColumn(title="需方", field="customerName"),
  755. ExportColumn(title="缺口类型", field="gapType"),
  756. ]
  757. class ContractGapExportBody(BaseModel):
  758. plan_no: Optional[str] = Field(default=None, alias="planNo")
  759. gap_type: Optional[str] = Field(default=None, alias="gapType")
  760. customer_keyword: Optional[str] = Field(default=None, alias="customerKeyword")
  761. plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom")
  762. plan_date_to: Optional[date] = Field(default=None, alias="planDateTo")
  763. q: Optional[str] = None
  764. ids: Optional[List[str]] = None
  765. columns: Optional[List[str]] = None
  766. model_config = {"populate_by_name": True}
  767. @router.post("/contract-gaps/export")
  768. def export_contract_gaps(
  769. body: ContractGapExportBody,
  770. db: Session = Depends(get_db),
  771. user: DbUserContext = Depends(get_db_current_user),
  772. ):
  773. try:
  774. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  775. _, items = list_plan_contract_gaps(
  776. db,
  777. tenant_no=tenant_no,
  778. user=user,
  779. page=1,
  780. page_size=EXPORT_MAX_ROWS,
  781. plan_no=body.plan_no,
  782. gap_type=body.gap_type,
  783. customer_keyword=body.customer_keyword,
  784. plan_date_from=body.plan_date_from,
  785. plan_date_to=body.plan_date_to,
  786. q=body.q,
  787. ids=body.ids,
  788. max_page_size=EXPORT_MAX_ROWS,
  789. )
  790. plan_type_labels = {"direct": "直销单", "inbound": "入库单", "outbound": "出库单"}
  791. gap_labels = {"supplier": "仅缺供方合同", "demander": "仅缺需方合同", "both": "双侧缺口"}
  792. rows = []
  793. for item in items:
  794. data = item.model_dump(by_alias=True)
  795. data["planType"] = plan_type_labels.get(str(data.get("planType") or ""), data.get("planType"))
  796. sides = set(data.get("gapSides") or [])
  797. if "supplier" in sides and "demander" in sides:
  798. data["gapType"] = "双侧缺口"
  799. elif "supplier" in sides:
  800. data["gapType"] = "仅缺供方合同"
  801. elif "demander" in sides:
  802. data["gapType"] = "仅缺需方合同"
  803. else:
  804. data["gapType"] = ""
  805. for k, v in list(data.items()):
  806. if hasattr(v, "isoformat"):
  807. data[k] = str(v)[:19]
  808. rows.append(data)
  809. cols = filter_export_columns(CONTRACT_GAP_EXPORT_COLUMNS, body.columns)
  810. date_str = datetime.now().strftime("%Y%m%d")
  811. return export_rows_to_xlsx_response(
  812. db, tenant_no=tenant_no, sheet_title="合同完善", columns=cols, rows=rows,
  813. filename_stem=f"计划合同完善_{date_str}",
  814. )
  815. except ExportValidationError as e:
  816. raise HTTPException(status_code=400, detail=str(e)) from e
  817. @router.put("/{id}/contracts/{side}", response_model=PlanContractGapRead)
  818. def put_plan_side_contracts(
  819. id: str,
  820. side: str,
  821. payload: PlanSideContractsUpdateBody,
  822. db: Session = Depends(get_db),
  823. user: DbUserContext = Depends(get_db_current_user),
  824. ) -> PlanContractGapRead:
  825. """单侧保存计划合同关联(合同补全)。"""
  826. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  827. plan = _get_plan(db, tenant_no, id, user)
  828. result = update_plan_side_contracts(
  829. db,
  830. tenant_no=tenant_no,
  831. plan=plan,
  832. side=side,
  833. contracts=payload.contracts,
  834. user_id=user.id,
  835. dept_id=user.dept_id,
  836. )
  837. plan.update_by = user.id
  838. db.commit()
  839. return result
  840. # =============================================================================
  841. # 创建计划
  842. # =============================================================================
  843. @router.post("/", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  844. def create_plan(
  845. payload: PlanCreate,
  846. db: Session = Depends(get_db),
  847. user: DbUserContext = Depends(get_db_current_user),
  848. ) -> PlanRead:
  849. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  850. # 生成计划编号
  851. plan_no = _generate_plan_no(db)
  852. # 单据类型由前端显式选择;未选则存空,不再自动推断为直销单
  853. plan_type = (payload.plan_type or "").strip()
  854. plan = PlPlan(
  855. id=generate_uuid(),
  856. tenant_no=tenant_no,
  857. plan_no=plan_no,
  858. create_date=_resolve_create_date(payload.create_date),
  859. plan_date=payload.plan_date,
  860. belongs_company=payload.belongs_company,
  861. plan_type=plan_type,
  862. follow_status="pending_arrange",
  863. transport_status=payload.transport_status or "pending",
  864. archive_status=payload.archive_status or "unarchived",
  865. complete_status=payload.complete_status or "no",
  866. contract_id=payload.contract_id,
  867. contract_no=payload.contract_no,
  868. supplier_id=payload.supplier_id,
  869. supplier_name=payload.supplier_name,
  870. destination_id=payload.destination_id,
  871. destination_name=payload.destination_name,
  872. customer_id=payload.customer_id,
  873. customer_name=payload.customer_name,
  874. contact_name=payload.contact_name,
  875. contact_phone=payload.contact_phone,
  876. party_a=payload.party_a,
  877. party_b=payload.party_b,
  878. contract_start_date=payload.contract_start_date,
  879. contract_end_date=payload.contract_end_date,
  880. delivery_method=payload.delivery_method,
  881. settlement_method=payload.settlement_method,
  882. has_freight=payload.has_freight,
  883. pickup_address=payload.pickup_address,
  884. pickup_lng=payload.pickup_lng,
  885. pickup_lat=payload.pickup_lat,
  886. pickup_time=payload.pickup_time,
  887. pickup_contact=payload.pickup_contact,
  888. pickup_notes=payload.pickup_notes,
  889. destination_address=payload.destination_address,
  890. destination_lng=payload.destination_lng,
  891. destination_lat=payload.destination_lat,
  892. delivery_time=payload.delivery_time,
  893. delivery_contact=payload.delivery_contact,
  894. destination_notes=payload.destination_notes,
  895. follow_user_id=payload.follow_user_id or user.id,
  896. follow_user_name=payload.follow_user_name or user.nick_name,
  897. remark=payload.remark,
  898. create_by=user.id,
  899. create_dept_id=user.dept_id,
  900. )
  901. db.add(plan)
  902. # 添加产品明细
  903. if payload.products:
  904. for idx, prod in enumerate(payload.products):
  905. pp = build_plan_product(
  906. tenant_no=tenant_no,
  907. plan_id=plan.id,
  908. plan_type=plan_type,
  909. prod=prod,
  910. order_num=idx,
  911. user_id=user.id,
  912. dept_id=user.dept_id,
  913. )
  914. db.add(pp)
  915. plan.total_amount = calc_plan_total_amount(payload.products or [])
  916. db.commit()
  917. db.refresh(plan)
  918. return plan
  919. @router.post("/create-full", response_model=PlanWithProducts, status_code=status.HTTP_201_CREATED)
  920. def create_plan_full(
  921. payload: PlanCreateFull,
  922. db: Session = Depends(get_db),
  923. user: DbUserContext = Depends(get_db_current_user),
  924. ) -> PlanWithProducts:
  925. """创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)"""
  926. validate_create_full(payload)
  927. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  928. supplier_contracts, demander_contracts = validate_plan_party_contracts(
  929. db,
  930. tenant_no,
  931. supplier_id=payload.supplier_id,
  932. supplier_name=payload.supplier_name,
  933. customer_id=payload.customer_id,
  934. customer_name=payload.customer_name,
  935. supplier_contracts=payload.supplier_contracts,
  936. demander_contracts=payload.demander_contracts,
  937. require_products=payload.products,
  938. )
  939. plan_no = _generate_plan_no(db)
  940. # 单据类型由前端显式选择;未选则存空,不再自动推断为直销单
  941. plan_type = (payload.plan_type or "").strip()
  942. has_vehicle = bool(payload.transport and payload.transport.vehicle_id)
  943. demander_name = (payload.customer_name or "").strip() or None
  944. plan = PlPlan(
  945. id=generate_uuid(),
  946. tenant_no=tenant_no,
  947. plan_no=plan_no,
  948. create_date=_resolve_create_date(payload.create_date),
  949. plan_date=payload.plan_date,
  950. belongs_company=payload.belongs_company,
  951. plan_type=plan_type,
  952. follow_status="pending_arrange",
  953. transport_status="pending",
  954. archive_status="unarchived",
  955. complete_status="no",
  956. contract_id=None,
  957. contract_no=None,
  958. supplier_id=payload.supplier_id,
  959. supplier_name=payload.supplier_name,
  960. destination_id=payload.customer_id or payload.destination_id,
  961. destination_name=demander_name or payload.destination_name,
  962. customer_id=payload.customer_id,
  963. customer_name=payload.customer_name,
  964. contact_name=payload.contact_name,
  965. contact_phone=payload.contact_phone,
  966. party_a=payload.party_a,
  967. party_b=payload.party_b,
  968. contract_start_date=payload.contract_start_date,
  969. contract_end_date=payload.contract_end_date,
  970. delivery_method=payload.delivery_method,
  971. settlement_method=payload.settlement_method,
  972. has_freight=payload.has_freight,
  973. pickup_address=payload.pickup_address,
  974. pickup_lng=payload.pickup_lng,
  975. pickup_lat=payload.pickup_lat,
  976. pickup_time=payload.pickup_time,
  977. pickup_contact=payload.pickup_contact,
  978. pickup_notes=payload.pickup_notes,
  979. destination_address=payload.destination_address,
  980. destination_lng=payload.destination_lng,
  981. destination_lat=payload.destination_lat,
  982. delivery_time=payload.delivery_time,
  983. delivery_contact=payload.delivery_contact,
  984. destination_notes=payload.destination_notes,
  985. total_amount=calc_plan_total_amount(payload.products or []),
  986. follow_user_id=payload.follow_user_id or user.id,
  987. follow_user_name=payload.follow_user_name or user.nick_name,
  988. remark=payload.remark,
  989. create_by=user.id,
  990. create_dept_id=user.dept_id,
  991. )
  992. db.add(plan)
  993. db.flush()
  994. plan_products: list[PlPlanProduct] = []
  995. for idx, prod in enumerate(payload.products or []):
  996. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  997. db,
  998. tenant_no,
  999. prod.product_id,
  1000. )
  1001. prod_payload = prod.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
  1002. pp = build_plan_product(
  1003. tenant_no=tenant_no,
  1004. plan_id=plan.id,
  1005. plan_type=plan_type,
  1006. prod=prod_payload,
  1007. order_num=idx,
  1008. user_id=user.id,
  1009. dept_id=user.dept_id,
  1010. )
  1011. db.add(pp)
  1012. plan_products.append(pp)
  1013. replace_plan_contracts(
  1014. db,
  1015. tenant_no=tenant_no,
  1016. plan_id=plan.id,
  1017. supplier_contracts=supplier_contracts,
  1018. demander_contracts=demander_contracts,
  1019. user_id=user.id,
  1020. dept_id=user.dept_id,
  1021. )
  1022. if has_vehicle and payload.transport:
  1023. apply_vehicle_prefill_to_plan(
  1024. db,
  1025. tenant_no=tenant_no,
  1026. plan=plan,
  1027. transport_payload=payload.transport,
  1028. products=plan_products,
  1029. user_id=user.id,
  1030. dept_id=user.dept_id,
  1031. )
  1032. db.commit()
  1033. db.refresh(plan)
  1034. result = PlanWithProducts.model_validate(plan)
  1035. result.products = plan_products
  1036. result.supplier_contracts = supplier_contracts
  1037. result.demander_contracts = demander_contracts
  1038. return result
  1039. # =============================================================================
  1040. # 从合同创建计划
  1041. # =============================================================================
  1042. @router.post("/create-from-contract", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  1043. def create_from_contract(
  1044. payload: CreateFromContractRequest,
  1045. db: Session = Depends(get_db),
  1046. user: DbUserContext = Depends(get_db_current_user),
  1047. ) -> PlanRead:
  1048. """从 CRM 合同发起计划"""
  1049. from app.models.crm_models import CrmContract, CrmContractProduct
  1050. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1051. # 查询合同信息
  1052. contract = (
  1053. db.query(CrmContract)
  1054. .filter(CrmContract.id == payload.contract_id, CrmContract.del_flag == 0)
  1055. .first()
  1056. )
  1057. if not contract:
  1058. raise HTTPException(status_code=404, detail="合同不存在")
  1059. assert_contract_selectable_for_plan(db, contract.id, tenant_no)
  1060. # 生成计划编号
  1061. plan_no = _generate_plan_no(db)
  1062. # 自动判断单据类型
  1063. plan_type = _determine_plan_type(contract.supplier_name, contract.customer_name)
  1064. plan = PlPlan(
  1065. id=generate_uuid(),
  1066. tenant_no=tenant_no,
  1067. plan_no=plan_no,
  1068. create_date=_resolve_create_date(payload.create_date),
  1069. plan_date=payload.plan_date,
  1070. belongs_company=contract.belongs_company or "shanxia",
  1071. plan_type=plan_type,
  1072. follow_status="pending_arrange",
  1073. transport_status="pending",
  1074. archive_status="unarchived",
  1075. complete_status="no",
  1076. contract_id=contract.id,
  1077. contract_no=contract.contract_no,
  1078. supplier_id=contract.supplier_id,
  1079. supplier_name=(contract.party_a or contract.supplier_name or "").strip() or None,
  1080. destination_id=contract.customer_id,
  1081. # 客户名称严格跟合同「客户名称」;目的地名称与客户保持一致(不再取需方可章名 party_b)
  1082. destination_name=(contract.customer_name or "").strip() or None,
  1083. customer_id=contract.customer_id,
  1084. customer_name=(contract.customer_name or "").strip() or None,
  1085. contact_name=contract.contact_name,
  1086. contact_phone=contract.contact_phone,
  1087. party_a=contract.party_a,
  1088. party_b=contract.party_b,
  1089. contract_start_date=contract.start_date,
  1090. contract_end_date=contract.end_date,
  1091. delivery_method=contract.delivery_method,
  1092. settlement_method=contract.settlement_method,
  1093. has_freight=1 if contract.settlement_method and "含运" in contract.settlement_method else 0,
  1094. follow_user_id=payload.follow_user_id or user.id,
  1095. follow_user_name=payload.follow_user_name or user.nick_name,
  1096. create_by=user.id,
  1097. create_dept_id=user.dept_id,
  1098. )
  1099. db.add(plan)
  1100. # 查询合同产品明细
  1101. contract_products = (
  1102. db.query(CrmContractProduct)
  1103. .filter(
  1104. CrmContractProduct.contract_id == contract.id,
  1105. CrmContractProduct.del_flag == 0,
  1106. )
  1107. .all()
  1108. )
  1109. # 添加产品明细
  1110. for idx, cp in enumerate(contract_products):
  1111. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  1112. db,
  1113. tenant_no,
  1114. cp.product_id,
  1115. )
  1116. pp = PlPlanProduct(
  1117. id=generate_uuid(),
  1118. tenant_no=tenant_no,
  1119. plan_id=plan.id,
  1120. product_id=cp.product_id,
  1121. product_no=cp.product_no,
  1122. product_name=cp.product_name,
  1123. brand_mode=cp.brand_mode,
  1124. brand_id=cp.brand_id,
  1125. brand_name=cp.brand_name,
  1126. model=cp.model,
  1127. unit=cp.unit or "吨",
  1128. is_explosive=is_explosive,
  1129. is_toxic=is_toxic,
  1130. inbound_estimate_qty=cp.quantity,
  1131. outbound_estimate_qty=cp.quantity,
  1132. order_num=idx,
  1133. create_by=user.id,
  1134. create_dept_id=user.dept_id,
  1135. )
  1136. db.add(pp)
  1137. db.commit()
  1138. db.refresh(plan)
  1139. return plan
  1140. # =============================================================================
  1141. # 获取计划详情
  1142. # =============================================================================
  1143. @router.get("/{id}", response_model=PlanWithProducts)
  1144. def get_plan(
  1145. id: str,
  1146. db: Session = Depends(get_db),
  1147. user: DbUserContext = Depends(get_db_current_user),
  1148. ) -> PlanWithProducts:
  1149. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1150. plan = _get_plan(db, tenant_no, id, user)
  1151. products = (
  1152. db.query(PlPlanProduct)
  1153. .filter(
  1154. PlPlanProduct.plan_id == id,
  1155. PlPlanProduct.del_flag == 0,
  1156. )
  1157. .order_by(PlPlanProduct.order_num)
  1158. .all()
  1159. )
  1160. hazard_map = fetch_hazard_flags_map(
  1161. db,
  1162. tenant_no,
  1163. [product.product_id for product in products if product.product_id],
  1164. )
  1165. brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
  1166. db,
  1167. tenant_no=tenant_no,
  1168. plan_product_ids=[p.id for p in products],
  1169. )
  1170. enriched_products = []
  1171. for product in products:
  1172. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  1173. db,
  1174. tenant_no,
  1175. product.product_id,
  1176. hazard_map=hazard_map,
  1177. )
  1178. item = PlanProductRead.model_validate(product)
  1179. raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
  1180. brand_qtys = (
  1181. [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
  1182. if raw_brand_qtys
  1183. else None
  1184. )
  1185. item = item.model_copy(
  1186. update={
  1187. "is_toxic": is_toxic,
  1188. "is_explosive": is_explosive,
  1189. "brand_qtys": brand_qtys,
  1190. }
  1191. )
  1192. enriched_products.append(item)
  1193. result = PlanWithProducts.model_validate(plan)
  1194. result.products = enriched_products
  1195. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  1196. db, tenant_no, [id]
  1197. ).get(id, ([], []))
  1198. result.supplier_contracts = supplier_contracts
  1199. result.demander_contracts = demander_contracts
  1200. return result
  1201. # =============================================================================
  1202. # 更新计划
  1203. # =============================================================================
  1204. @router.put("/{id}", response_model=PlanRead)
  1205. def update_plan(
  1206. id: str,
  1207. payload: PlanUpdate,
  1208. db: Session = Depends(get_db),
  1209. user: DbUserContext = Depends(get_db_current_user),
  1210. ) -> PlanRead:
  1211. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1212. plan = _get_plan(db, tenant_no, id, user)
  1213. if plan.follow_status == "cancelled":
  1214. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  1215. # 更新主表字段(跟进状态仅由业务动作接口维护,忽略客户端写入)
  1216. update_data = payload.model_dump(
  1217. exclude_unset=True,
  1218. exclude={"products", "transport", "follow_status", "supplier_contracts", "demander_contracts"},
  1219. )
  1220. for key, value in update_data.items():
  1221. setattr(plan, key, value)
  1222. # 新模型:单一合同字段不再作为写入源
  1223. if "supplier_contracts" in payload.model_fields_set or "demander_contracts" in payload.model_fields_set:
  1224. plan.contract_id = None
  1225. plan.contract_no = None
  1226. if payload.customer_id is not None or payload.customer_name is not None:
  1227. plan.destination_id = plan.customer_id
  1228. plan.destination_name = plan.customer_name
  1229. supplier_id = plan.supplier_id
  1230. supplier_name = plan.supplier_name
  1231. customer_id = plan.customer_id
  1232. customer_name = plan.customer_name
  1233. supplier_contracts_in = (
  1234. payload.supplier_contracts
  1235. if "supplier_contracts" in payload.model_fields_set
  1236. else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[0]
  1237. )
  1238. demander_contracts_in = (
  1239. payload.demander_contracts
  1240. if "demander_contracts" in payload.model_fields_set
  1241. else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[1]
  1242. )
  1243. # 编辑保存若带了产品或任一侧合同字段,按新规则完整校验供方/需方
  1244. if (
  1245. payload.products is not None
  1246. or "supplier_contracts" in payload.model_fields_set
  1247. or "demander_contracts" in payload.model_fields_set
  1248. or payload.supplier_id is not None
  1249. or payload.customer_id is not None
  1250. ):
  1251. supplier_contracts, demander_contracts = validate_plan_party_contracts(
  1252. db,
  1253. tenant_no,
  1254. supplier_id=supplier_id,
  1255. supplier_name=supplier_name,
  1256. customer_id=customer_id,
  1257. customer_name=customer_name,
  1258. supplier_contracts=supplier_contracts_in,
  1259. demander_contracts=demander_contracts_in,
  1260. require_products=payload.products if payload.products is not None else None,
  1261. )
  1262. replace_plan_contracts(
  1263. db,
  1264. tenant_no=tenant_no,
  1265. plan_id=id,
  1266. supplier_contracts=supplier_contracts,
  1267. demander_contracts=demander_contracts,
  1268. user_id=user.id,
  1269. dept_id=user.dept_id,
  1270. )
  1271. else:
  1272. supplier_contracts, demander_contracts = load_plan_contracts_grouped(db, tenant_no, [id]).get(
  1273. id, ([], [])
  1274. )
  1275. plan_type = payload.plan_type or plan.plan_type
  1276. # 更新产品明细
  1277. if payload.products is not None:
  1278. existing_products = (
  1279. db.query(PlPlanProduct)
  1280. .filter(
  1281. PlPlanProduct.plan_id == id,
  1282. PlPlanProduct.del_flag == 0,
  1283. )
  1284. .all()
  1285. )
  1286. old_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, existing_products)
  1287. cert_by_product_id = {
  1288. row.product_id: (
  1289. row.purchase_cert_url,
  1290. row.transport_cert_url,
  1291. row.legal_use_cert_url,
  1292. )
  1293. for row in existing_products
  1294. }
  1295. db.query(PlPlanProduct).filter(
  1296. PlPlanProduct.plan_id == id,
  1297. PlPlanProduct.del_flag == 0,
  1298. ).update({"del_flag": 1})
  1299. plan_products: list[PlPlanProduct] = []
  1300. for idx, prod in enumerate(payload.products):
  1301. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  1302. db,
  1303. tenant_no,
  1304. prod.product_id,
  1305. )
  1306. purchase_cert = prod.purchase_cert_url
  1307. transport_cert = prod.transport_cert_url
  1308. legal_use_cert = prod.legal_use_cert_url
  1309. old_certs = cert_by_product_id.get(prod.product_id, (None, None, None))
  1310. old_purchase, old_transport, old_legal_use = old_certs
  1311. purchase_cert = purchase_cert or old_purchase
  1312. transport_cert = transport_cert or old_transport
  1313. legal_use_cert = legal_use_cert or old_legal_use
  1314. prod_payload = prod.model_copy(
  1315. update={
  1316. "is_toxic": is_toxic,
  1317. "is_explosive": is_explosive,
  1318. "purchase_cert_url": purchase_cert,
  1319. "transport_cert_url": transport_cert,
  1320. "legal_use_cert_url": legal_use_cert,
  1321. }
  1322. )
  1323. pp = build_plan_product(
  1324. tenant_no=tenant_no,
  1325. plan_id=id,
  1326. plan_type=plan_type,
  1327. prod=prod_payload,
  1328. order_num=idx,
  1329. user_id=user.id,
  1330. dept_id=user.dept_id,
  1331. )
  1332. db.add(pp)
  1333. plan_products.append(pp)
  1334. new_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, payload.products)
  1335. if old_hazard_product_ids != new_hazard_product_ids:
  1336. clear_hazard_cert_ready(plan)
  1337. plan.total_amount = calc_plan_total_amount(payload.products)
  1338. else:
  1339. plan_products = (
  1340. db.query(PlPlanProduct)
  1341. .filter(
  1342. PlPlanProduct.plan_id == id,
  1343. PlPlanProduct.del_flag == 0,
  1344. )
  1345. .all()
  1346. )
  1347. # 同步运输预选 / 关联运输单运费
  1348. if payload.transport is not None:
  1349. if payload.transport.vehicle_id:
  1350. sync_transport_prefill_for_plan(
  1351. db,
  1352. tenant_no=tenant_no,
  1353. plan=plan,
  1354. transport_payload=payload.transport,
  1355. products=plan_products,
  1356. user_id=user.id,
  1357. dept_id=user.dept_id,
  1358. )
  1359. else:
  1360. clear_draft_transport_for_plan(
  1361. db,
  1362. tenant_no=tenant_no,
  1363. plan=plan,
  1364. user_id=user.id,
  1365. )
  1366. plan.update_by = user.id
  1367. db.commit()
  1368. db.refresh(plan)
  1369. result = PlanRead.model_validate(plan)
  1370. result.supplier_contracts = supplier_contracts
  1371. result.demander_contracts = demander_contracts
  1372. return result
  1373. @router.patch("/{id}/product-certs", response_model=PlanWithProducts)
  1374. def patch_plan_product_certs(
  1375. id: str,
  1376. payload: PlanProductCertsUpdate,
  1377. db: Session = Depends(get_db),
  1378. user: DbUserContext = Depends(get_db_current_user),
  1379. ) -> PlanWithProducts:
  1380. """更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)"""
  1381. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1382. _get_plan(db, tenant_no, id, user)
  1383. update_plan_product_certs(
  1384. db,
  1385. tenant_no=tenant_no,
  1386. plan_id=id,
  1387. items=payload.products,
  1388. user_id=user.id,
  1389. )
  1390. plan = _get_plan(db, tenant_no, id, user)
  1391. products = (
  1392. db.query(PlPlanProduct)
  1393. .filter(
  1394. PlPlanProduct.plan_id == id,
  1395. PlPlanProduct.del_flag == 0,
  1396. )
  1397. .order_by(PlPlanProduct.order_num)
  1398. .all()
  1399. )
  1400. result = PlanWithProducts.model_validate(plan)
  1401. result.products = products
  1402. return result
  1403. @router.patch("/{id}/hazard-cert-ready", response_model=PlanWithProducts)
  1404. def patch_plan_hazard_cert_ready(
  1405. id: str,
  1406. payload: HazardCertReadyBody,
  1407. db: Session = Depends(get_db),
  1408. user: DbUserContext = Depends(get_db_current_user),
  1409. ) -> PlanWithProducts:
  1410. """标记或取消危化品证书齐备(人工确认)"""
  1411. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1412. _get_plan(db, tenant_no, id, user)
  1413. set_hazard_cert_ready(
  1414. db,
  1415. tenant_no=tenant_no,
  1416. plan_id=id,
  1417. ready=payload.ready,
  1418. user_id=user.id,
  1419. )
  1420. plan = _get_plan(db, tenant_no, id, user)
  1421. products = (
  1422. db.query(PlPlanProduct)
  1423. .filter(
  1424. PlPlanProduct.plan_id == id,
  1425. PlPlanProduct.del_flag == 0,
  1426. )
  1427. .order_by(PlPlanProduct.order_num)
  1428. .all()
  1429. )
  1430. result = PlanWithProducts.model_validate(plan)
  1431. result.products = products
  1432. return result
  1433. @router.patch("/{id}/erp-entered", response_model=PlanWithProducts)
  1434. def patch_plan_erp_entered(
  1435. id: str,
  1436. payload: ErpEnteredBody,
  1437. db: Session = Depends(get_db),
  1438. user: DbUserContext = Depends(get_db_current_user),
  1439. ) -> PlanWithProducts:
  1440. """标记或取消运输计划已录入 ERP。"""
  1441. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1442. set_plan_erp_entered(
  1443. db,
  1444. tenant_no=tenant_no,
  1445. plan_id=id,
  1446. entered=payload.entered,
  1447. user_id=user.id,
  1448. )
  1449. plan = _get_plan(db, tenant_no, id, user)
  1450. products = (
  1451. db.query(PlPlanProduct)
  1452. .filter(
  1453. PlPlanProduct.plan_id == id,
  1454. PlPlanProduct.del_flag == 0,
  1455. )
  1456. .order_by(PlPlanProduct.order_num)
  1457. .all()
  1458. )
  1459. result = PlanWithProducts.model_validate(plan)
  1460. result.products = products
  1461. result.document_attachments = load_plan_document_attachments_by_plan_ids(
  1462. db,
  1463. tenant_no=tenant_no,
  1464. plan_ids=[id],
  1465. ).get(id, [])
  1466. return result
  1467. def _build_driver_url(token: str) -> str:
  1468. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  1469. return f"{origin}/#/public/pl/transport/{token}"
  1470. @router.get("/{id}/arrange-info", response_model=PlanArrangeInfoRead)
  1471. def get_plan_arrange_info(
  1472. id: str,
  1473. db: Session = Depends(get_db),
  1474. user: DbUserContext = Depends(get_db_current_user),
  1475. ) -> PlanArrangeInfoRead:
  1476. """获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。"""
  1477. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1478. plan = _get_plan(db, tenant_no, id, user)
  1479. empty = PlanArrangeInfoRead(driver_url=None, license_plate=None, site_records=[], inventory_records=[])
  1480. if plan.follow_status == "cancelled" or plan.transport_status != "arranged" or not plan.transport_id:
  1481. return empty
  1482. transport = (
  1483. db.query(PlTransport)
  1484. .filter(
  1485. PlTransport.id == plan.transport_id,
  1486. PlTransport.tenant_no == tenant_no,
  1487. PlTransport.del_flag == 0,
  1488. )
  1489. .first()
  1490. )
  1491. if not transport or transport.transport_status == "cancelled":
  1492. return empty
  1493. token = (transport.driver_access_token or "").strip()
  1494. driver_url = _build_driver_url(token) if token else None
  1495. site_rows = (
  1496. db.query(PlTransportSiteLink)
  1497. .filter(
  1498. PlTransportSiteLink.tenant_no == tenant_no,
  1499. PlTransportSiteLink.plan_id == id,
  1500. PlTransportSiteLink.transport_id == transport.id,
  1501. PlTransportSiteLink.del_flag == 0,
  1502. )
  1503. .order_by(PlTransportSiteLink.create_time.asc())
  1504. .all()
  1505. )
  1506. product_name_by_id = {
  1507. item.id: item.product_name or ""
  1508. for item in db.query(PlPlanProduct)
  1509. .filter(
  1510. PlPlanProduct.plan_id == id,
  1511. PlPlanProduct.del_flag == 0,
  1512. )
  1513. .all()
  1514. }
  1515. site_records: list[TransportSiteLinkRead] = [
  1516. build_transport_site_link_read_from_row(
  1517. row,
  1518. product_name=product_name_by_id.get(row.plan_product_id, row.product or ""),
  1519. )
  1520. for row in site_rows
  1521. ]
  1522. links = (
  1523. db.query(PlTransportDbLink)
  1524. .filter(
  1525. PlTransportDbLink.tenant_no == tenant_no,
  1526. PlTransportDbLink.plan_id == id,
  1527. PlTransportDbLink.transport_id == transport.id,
  1528. PlTransportDbLink.del_flag == 0,
  1529. )
  1530. .all()
  1531. )
  1532. inventory_records: list[TransportInventoryLinkRead] = []
  1533. for link in links:
  1534. product = (
  1535. db.query(PlPlanProduct)
  1536. .filter(
  1537. PlPlanProduct.id == link.plan_product_id,
  1538. PlPlanProduct.del_flag == 0,
  1539. )
  1540. .first()
  1541. )
  1542. inventory = (
  1543. db.query(DbInboundOutbound)
  1544. .filter(
  1545. DbInboundOutbound.id == link.db_inventory_id,
  1546. DbInboundOutbound.tenant_no == tenant_no,
  1547. DbInboundOutbound.del_flag == 0,
  1548. )
  1549. .first()
  1550. )
  1551. inventory_records.append(
  1552. build_transport_inventory_link_read(
  1553. db,
  1554. plan_product_id=link.plan_product_id,
  1555. product_name=(product.product_name if product else "") or "",
  1556. db_purchase_plan_id=link.db_purchase_plan_id,
  1557. db_inventory_id=link.db_inventory_id,
  1558. batch_no=(inventory.batch_no if inventory else "") or "",
  1559. plan_no=plan.plan_no or "",
  1560. )
  1561. )
  1562. io_order_brief = None
  1563. active_io = get_active_io_order_for_plan(db, tenant_no, plan)
  1564. if active_io:
  1565. kind, header = active_io
  1566. from app.services.pl_io_order_service import build_io_order_site_url
  1567. token = (header.site_access_token or "").strip()
  1568. io_order_brief = TransportIoOrderBrief(
  1569. kind=kind,
  1570. order_id=header.id,
  1571. order_no=header.order_no,
  1572. site_access_token=token or None,
  1573. site_url=build_io_order_site_url(kind, token) if token else None,
  1574. status=header.status,
  1575. )
  1576. if not site_records and token and header.status != "voided":
  1577. site_records = [
  1578. TransportSiteLinkRead(
  1579. plan_product_id="",
  1580. product_name=header.order_no,
  1581. site_link_id=header.id,
  1582. plan_no=plan.plan_no or "",
  1583. business_type="入库" if kind == "inbound" else "出库",
  1584. site_access_token=token,
  1585. site_url=build_io_order_site_url(kind, token),
  1586. )
  1587. ]
  1588. return PlanArrangeInfoRead(
  1589. driver_url=driver_url,
  1590. license_plate=transport.license_plate or plan.license_plate,
  1591. site_records=site_records,
  1592. inventory_records=inventory_records,
  1593. io_order=io_order_brief,
  1594. )
  1595. # =============================================================================
  1596. # 跟进状态:等通知 / 取消等通知
  1597. # =============================================================================
  1598. @router.post("/{id}/wait-notice", response_model=PlanRead)
  1599. def mark_plan_wait_notice(
  1600. id: str,
  1601. db: Session = Depends(get_db),
  1602. user: DbUserContext = Depends(get_db_current_user),
  1603. ) -> PlanRead:
  1604. """将跟进状态从「待安排」设为「等通知」。"""
  1605. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1606. plan = _get_plan(db, tenant_no, id, user)
  1607. if plan.follow_status == "cancelled":
  1608. raise HTTPException(status_code=400, detail="已取消的计划不能标记等通知")
  1609. if plan.archive_status == "archived":
  1610. raise HTTPException(status_code=400, detail="已归档的计划不能标记等通知")
  1611. if plan.follow_status != "pending_arrange":
  1612. raise HTTPException(status_code=400, detail="仅待安排状态可标记为等通知")
  1613. plan.follow_status = "wait_notice"
  1614. plan.update_by = user.id
  1615. db.commit()
  1616. db.refresh(plan)
  1617. return plan
  1618. @router.post("/{id}/cancel-wait-notice", response_model=PlanRead)
  1619. def cancel_plan_wait_notice(
  1620. id: str,
  1621. db: Session = Depends(get_db),
  1622. user: DbUserContext = Depends(get_db_current_user),
  1623. ) -> PlanRead:
  1624. """将跟进状态从「等通知」改回「待安排」。"""
  1625. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1626. plan = _get_plan(db, tenant_no, id, user)
  1627. if plan.follow_status != "wait_notice":
  1628. raise HTTPException(status_code=400, detail="仅等通知状态可取消等通知")
  1629. if plan.archive_status == "archived":
  1630. raise HTTPException(status_code=400, detail="已归档的计划不能取消等通知")
  1631. plan.follow_status = "pending_arrange"
  1632. plan.update_by = user.id
  1633. db.commit()
  1634. db.refresh(plan)
  1635. return plan
  1636. FORCE_FOLLOW_STATUS_PERM = "pl:plan:force-follow-status"
  1637. ALLOWED_FORCE_FOLLOW_STATUSES = frozenset(
  1638. {"wait_notice", "pending_arrange", "arranged", "completed", "cancelled"}
  1639. )
  1640. @router.post("/{id}/force-follow-status", response_model=PlanRead)
  1641. def force_plan_follow_status(
  1642. id: str,
  1643. payload: PlanForceFollowStatusBody,
  1644. db: Session = Depends(get_db),
  1645. user: DbUserContext = Depends(get_db_current_user),
  1646. ) -> PlanRead:
  1647. """强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。"""
  1648. if user.role != SUPER_ADMIN_ROLE:
  1649. codes = get_user_menu_codes(db, user.id)
  1650. if FORCE_FOLLOW_STATUS_PERM not in codes:
  1651. raise HTTPException(status_code=403, detail="无强制修改跟进状态权限")
  1652. next_status = (payload.follow_status or "").strip()
  1653. if next_status not in ALLOWED_FORCE_FOLLOW_STATUSES:
  1654. raise HTTPException(
  1655. status_code=400,
  1656. detail="跟进状态仅支持:等通知/待安排/已安排/已完成/已取消",
  1657. )
  1658. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1659. plan = _get_plan(db, tenant_no, id, user)
  1660. plan.follow_status = next_status
  1661. plan.update_by = user.id
  1662. db.commit()
  1663. db.refresh(plan)
  1664. return plan
  1665. # =============================================================================
  1666. # 取消计划
  1667. # =============================================================================
  1668. @router.post("/{id}/cancel", response_model=PlanRead)
  1669. def cancel_plan(
  1670. id: str,
  1671. db: Session = Depends(get_db),
  1672. user: DbUserContext = Depends(get_db_current_user),
  1673. ) -> PlanRead:
  1674. """作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。"""
  1675. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1676. plan = _get_plan(db, tenant_no, id, user)
  1677. if plan.follow_status == "cancelled":
  1678. raise HTTPException(status_code=400, detail="计划已作废")
  1679. if plan.archive_status == "archived":
  1680. raise HTTPException(status_code=400, detail="已归档的计划不能作废")
  1681. if plan.transport_id:
  1682. transport = (
  1683. db.query(PlTransport)
  1684. .filter(
  1685. PlTransport.id == plan.transport_id,
  1686. PlTransport.tenant_no == tenant_no,
  1687. PlTransport.del_flag == 0,
  1688. )
  1689. .first()
  1690. )
  1691. if transport:
  1692. try:
  1693. revoke_regeneratable_dibang_for_transport(
  1694. db,
  1695. tenant_no=tenant_no,
  1696. transport_id=transport.id,
  1697. user_id=user.id,
  1698. )
  1699. except ValueError as exc:
  1700. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1701. revoke_site_links_for_transport(
  1702. db,
  1703. tenant_no=tenant_no,
  1704. transport_id=transport.id,
  1705. user_id=user.id,
  1706. )
  1707. transport.transport_status = "cancelled"
  1708. transport.update_by = user.id
  1709. void_io_orders_for_plan(
  1710. db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id
  1711. )
  1712. plan.follow_status = "cancelled"
  1713. plan.transport_status = "pending"
  1714. plan.update_by = user.id
  1715. db.commit()
  1716. db.refresh(plan)
  1717. return plan
  1718. # =============================================================================
  1719. # 归档计划
  1720. # =============================================================================
  1721. @router.post("/{id}/archive", response_model=PlanWithProducts)
  1722. def archive_plan(
  1723. id: str,
  1724. payload: Optional[PlanArchiveBody] = Body(default=None),
  1725. db: Session = Depends(get_db),
  1726. user: DbUserContext = Depends(get_db_current_user),
  1727. ) -> PlanWithProducts:
  1728. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1729. plan = _get_plan(db, tenant_no, id, user)
  1730. if plan.follow_status == "cancelled":
  1731. raise HTTPException(status_code=400, detail="已作废的计划不能归档")
  1732. already_archived = plan.archive_status == "archived"
  1733. if not already_archived:
  1734. products = (
  1735. db.query(PlPlanProduct)
  1736. .filter(
  1737. PlPlanProduct.plan_id == plan.id,
  1738. PlPlanProduct.tenant_no == tenant_no,
  1739. PlPlanProduct.del_flag == 0,
  1740. )
  1741. .order_by(PlPlanProduct.order_num)
  1742. .all()
  1743. )
  1744. validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
  1745. if payload is None:
  1746. if already_archived:
  1747. raise HTTPException(status_code=400, detail="计划已归档,请提交归档表单以调整内容")
  1748. plan.archive_status = "archived"
  1749. plan.complete_status = "yes"
  1750. plan.follow_status = "completed"
  1751. plan.update_by = user.id
  1752. db.commit()
  1753. return get_plan(id, db, user)
  1754. apply_archive_product_quantities(
  1755. db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
  1756. )
  1757. apply_archive_inventory_for_plan(
  1758. db,
  1759. tenant_no=tenant_no,
  1760. plan=plan,
  1761. user_id=user.id,
  1762. dept_id=user.dept_id or "",
  1763. )
  1764. if payload.freight is not None:
  1765. apply_archive_freight(
  1766. db, tenant_no=tenant_no, plan=plan, freight=payload.freight, user_id=user.id
  1767. )
  1768. elif int(plan.has_freight or 0) == 1 and not already_archived:
  1769. if not plan.transport_id:
  1770. raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
  1771. if not already_archived:
  1772. plan.archive_status = "archived"
  1773. plan.complete_status = "yes"
  1774. plan.follow_status = "completed"
  1775. plan.update_by = user.id
  1776. record_plan_change(
  1777. db,
  1778. tenant_no=tenant_no,
  1779. plan_id=plan.id,
  1780. change_type="archive_adjust" if already_archived else "archive",
  1781. change_content="调整归档内容" if already_archived else "运输计划归档",
  1782. user_id=user.id,
  1783. user_name=user.name or None,
  1784. dept_id=user.dept_id,
  1785. )
  1786. db.commit()
  1787. return get_plan(id, db, user)
  1788. @router.patch("/{id}/driver-feedback", response_model=PlanWithProducts)
  1789. def update_plan_driver_feedback(
  1790. id: str,
  1791. payload: PlanDriverFeedbackUpdateBody,
  1792. db: Session = Depends(get_db),
  1793. user: DbUserContext = Depends(get_db_current_user),
  1794. ) -> PlanWithProducts:
  1795. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1796. plan = _get_plan(db, tenant_no, id, user)
  1797. apply_plan_driver_feedback(
  1798. db,
  1799. tenant_no=tenant_no,
  1800. plan=plan,
  1801. payload=payload,
  1802. user_id=user.id,
  1803. )
  1804. record_plan_change(
  1805. db,
  1806. tenant_no=tenant_no,
  1807. plan_id=plan.id,
  1808. change_type="feedback_adjust",
  1809. change_content="归档人员更新司机回传信息",
  1810. user_id=user.id,
  1811. user_name=user.name or None,
  1812. dept_id=user.dept_id,
  1813. )
  1814. plan.update_by = user.id
  1815. db.commit()
  1816. return get_plan(id, db, user)
  1817. @router.patch("/{id}/site-feedback/{link_id}", response_model=PlanWithProducts)
  1818. def update_plan_site_feedback(
  1819. id: str,
  1820. link_id: str,
  1821. payload: PlanSiteFeedbackUpdateBody,
  1822. db: Session = Depends(get_db),
  1823. user: DbUserContext = Depends(get_db_current_user),
  1824. ) -> PlanWithProducts:
  1825. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1826. plan = _get_plan(db, tenant_no, id, user)
  1827. link = apply_plan_site_feedback(
  1828. db,
  1829. tenant_no=tenant_no,
  1830. plan=plan,
  1831. link_id=link_id,
  1832. payload=payload,
  1833. user_id=user.id,
  1834. )
  1835. product_label = (link.product or "现场").strip()
  1836. record_plan_change(
  1837. db,
  1838. tenant_no=tenant_no,
  1839. plan_id=plan.id,
  1840. change_type="feedback_adjust",
  1841. change_content=f"归档人员更新现场回传信息({product_label})",
  1842. user_id=user.id,
  1843. user_name=user.name or None,
  1844. dept_id=user.dept_id,
  1845. )
  1846. plan.update_by = user.id
  1847. db.commit()
  1848. return get_plan(id, db, user)
  1849. # =============================================================================
  1850. # 入库单打印模板
  1851. # =============================================================================
  1852. @router.get("/{id}/inbound-slip-template")
  1853. def get_inbound_slip_template(
  1854. id: str,
  1855. db: Session = Depends(get_db),
  1856. user: DbUserContext = Depends(get_db_current_user),
  1857. ) -> str:
  1858. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1859. plan = _get_plan(db, tenant_no, id, user)
  1860. return plan.slip_template_content or ""
  1861. @router.put("/{id}/inbound-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  1862. def save_inbound_slip_template(
  1863. id: str,
  1864. payload: PlanInboundSlipTemplateSave,
  1865. db: Session = Depends(get_db),
  1866. user: DbUserContext = Depends(get_db_current_user),
  1867. ) -> None:
  1868. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1869. plan = _get_plan(db, tenant_no, id, user)
  1870. if plan.plan_type != "inbound":
  1871. raise HTTPException(status_code=400, detail="仅入库单计划支持保存入库单打印模板")
  1872. if plan.transport_status != "arranged":
  1873. raise HTTPException(status_code=400, detail="请先完成运输安排后再保存入库单打印模板")
  1874. plan.slip_template_content = payload.slip_template_content or ""
  1875. plan.update_by = user.id
  1876. db.commit()
  1877. # =============================================================================
  1878. # 送(提)货单打印模板
  1879. # =============================================================================
  1880. @router.get("/{id}/delivery-slip-template")
  1881. def get_delivery_slip_template(
  1882. id: str,
  1883. db: Session = Depends(get_db),
  1884. user: DbUserContext = Depends(get_db_current_user),
  1885. ) -> str:
  1886. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1887. plan = _get_plan(db, tenant_no, id, user)
  1888. return plan.delivery_slip_template_content or ""
  1889. @router.put("/{id}/delivery-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  1890. def save_delivery_slip_template(
  1891. id: str,
  1892. payload: PlanDeliverySlipTemplateSave,
  1893. db: Session = Depends(get_db),
  1894. user: DbUserContext = Depends(get_db_current_user),
  1895. ) -> None:
  1896. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1897. plan = _get_plan(db, tenant_no, id, user)
  1898. if plan.plan_type == "inbound":
  1899. raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
  1900. if plan.transport_status != "arranged":
  1901. raise HTTPException(status_code=400, detail="请先完成运输安排后再保存送(提)货单打印模板")
  1902. plan.delivery_slip_template_content = payload.delivery_slip_template_content or ""
  1903. plan.update_by = user.id
  1904. db.commit()
  1905. # =============================================================================
  1906. # 分装单打印模板
  1907. # =============================================================================
  1908. @router.get("/{id}/subpack-slip-template")
  1909. def get_subpack_slip_template(
  1910. id: str,
  1911. db: Session = Depends(get_db),
  1912. user: DbUserContext = Depends(get_db_current_user),
  1913. ) -> str:
  1914. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1915. plan = _get_plan(db, tenant_no, id, user)
  1916. return plan.subpack_slip_template_content or ""
  1917. @router.put("/{id}/subpack-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  1918. def save_subpack_slip_template(
  1919. id: str,
  1920. payload: PlanSubpackSlipTemplateSave,
  1921. db: Session = Depends(get_db),
  1922. user: DbUserContext = Depends(get_db_current_user),
  1923. ) -> None:
  1924. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1925. plan = _get_plan(db, tenant_no, id, user)
  1926. if plan.plan_type == "inbound":
  1927. raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
  1928. plan.subpack_slip_template_content = payload.subpack_slip_template_content or ""
  1929. plan.update_by = user.id
  1930. db.commit()
  1931. @router.post("/{id}/subpack-slip-no")
  1932. def ensure_subpack_slip_no(
  1933. id: str,
  1934. db: Session = Depends(get_db),
  1935. user: DbUserContext = Depends(get_db_current_user),
  1936. ) -> str:
  1937. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1938. plan = _get_plan(db, tenant_no, id, user)
  1939. if plan.plan_type == "inbound":
  1940. raise HTTPException(status_code=400, detail="入库单计划不支持分装单号")
  1941. slip_no = _ensure_subpack_slip_no(db, tenant_no, plan)
  1942. plan.update_by = user.id
  1943. db.commit()
  1944. return slip_no
  1945. # =============================================================================
  1946. # 删除计划
  1947. # =============================================================================
  1948. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  1949. def delete_plan(
  1950. id: str,
  1951. db: Session = Depends(get_db),
  1952. user: DbUserContext = Depends(get_db_current_user),
  1953. ) -> None:
  1954. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1955. plan = _get_plan(db, tenant_no, id, user)
  1956. if plan.follow_status not in ("pending_arrange", "wait_notice"):
  1957. raise HTTPException(status_code=400, detail="只能删除待安排或等通知且未安排运输的计划")
  1958. if plan.transport_status != "pending":
  1959. raise HTTPException(status_code=400, detail="已安排运输的计划不能删除")
  1960. if plan.archive_status == "archived":
  1961. raise HTTPException(status_code=400, detail="已归档的计划不能删除")
  1962. assert_row_deletable_by_creator(plan, user_info_from_context(user))
  1963. plan.del_flag = 1
  1964. plan.update_by = user.id
  1965. db.query(PlPlanProduct).filter(
  1966. PlPlanProduct.plan_id == id,
  1967. PlPlanProduct.tenant_no == tenant_no,
  1968. PlPlanProduct.del_flag == 0,
  1969. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  1970. db.query(PlPlanTransport).filter(
  1971. PlPlanTransport.plan_id == id,
  1972. PlPlanTransport.tenant_no == tenant_no,
  1973. PlPlanTransport.del_flag == 0,
  1974. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  1975. db.commit()
  1976. # =============================================================================
  1977. # 辅助函数
  1978. # =============================================================================
  1979. def _get_plan(db: Session, tenant_no: str, plan_id: str, user: DbUserContext) -> PlPlan:
  1980. plan = (
  1981. db.query(PlPlan)
  1982. .filter(
  1983. PlPlan.id == plan_id,
  1984. PlPlan.tenant_no == tenant_no,
  1985. PlPlan.del_flag == 0,
  1986. )
  1987. .first()
  1988. )
  1989. if not plan:
  1990. raise HTTPException(status_code=404, detail="计划不存在")
  1991. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  1992. return plan