plan_validation_service.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442
  1. from __future__ import annotations
  2. """方案校验服务:用确定性规则校验行程方案的航班/酒店/活动安排/预算合理性,产出结构化问题清单与预算估算。不调用大模型。"""
  3. from datetime import datetime, timedelta
  4. from typing import Any
  5. from app.schemas.itinerary import (
  6. ItineraryActivity,
  7. ItineraryPlan,
  8. )
  9. from app.schemas.place import (
  10. MapResearchResult,
  11. )
  12. from app.schemas.route import (
  13. RouteEvaluationResult,
  14. )
  15. from app.schemas.selection import (
  16. CandidateSelectionResult,
  17. )
  18. from app.schemas.travel_request import (
  19. TravelRequest,
  20. )
  21. from app.schemas.validation import (
  22. BudgetEstimate,
  23. PlanValidationResult,
  24. ValidationIssue,
  25. )
  26. class PlanValidationService:
  27. """对Planner生成的行程进行确定性校验。
  28. 不调用大模型,不调用外部API。
  29. """
  30. def validate(
  31. self,
  32. *,
  33. request: TravelRequest,
  34. plan: ItineraryPlan,
  35. selection: CandidateSelectionResult,
  36. map_result: MapResearchResult,
  37. route_result: RouteEvaluationResult,
  38. ) -> PlanValidationResult:
  39. """校验完整行程方案:验证航班与酒店ID是否来自候选列表、活动地点是否存在、预算是否合理,返回校验结果与问题清单。"""
  40. issues: list[ValidationIssue] = []
  41. flight_data = self._validate_flight(
  42. plan=plan,
  43. selection=selection,
  44. issues=issues,
  45. )
  46. hotel_data = self._validate_hotel(
  47. request=request,
  48. plan=plan,
  49. selection=selection,
  50. route_result=route_result,
  51. issues=issues,
  52. )
  53. self._validate_days(
  54. request=request,
  55. plan=plan,
  56. issues=issues,
  57. )
  58. self._validate_activities(
  59. request=request,
  60. plan=plan,
  61. map_result=map_result,
  62. issues=issues,
  63. )
  64. self._validate_boundary_days(
  65. request=request,
  66. plan=plan,
  67. flight_data=flight_data,
  68. issues=issues,
  69. )
  70. budget = self._build_budget(
  71. request=request,
  72. plan=plan,
  73. flight_quote=flight_data.get(
  74. "price"
  75. ),
  76. hotel_estimate=hotel_data.get(
  77. "hotel_estimate"
  78. ),
  79. issues=issues,
  80. )
  81. error_issues = [
  82. issue
  83. for issue in issues
  84. if issue.severity == "error"
  85. ]
  86. warning_issues = [
  87. issue
  88. for issue in issues
  89. if issue.severity == "warning"
  90. ]
  91. revision_feedback = [
  92. issue.message
  93. for issue in error_issues
  94. ]
  95. return PlanValidationResult(
  96. is_valid=not error_issues,
  97. error_count=len(error_issues),
  98. warning_count=len(warning_issues),
  99. issues=issues,
  100. budget=budget,
  101. revision_feedback=revision_feedback,
  102. )
  103. def _validate_flight(
  104. self,
  105. *,
  106. plan: ItineraryPlan,
  107. selection: CandidateSelectionResult,
  108. issues: list[ValidationIssue],
  109. ) -> dict[str, Any]:
  110. """确认Planner选择的航班来自真实候选。"""
  111. selected = plan.selected_flight
  112. if (
  113. selected.flight_type
  114. != selection.flight_type
  115. ):
  116. self._add_issue(
  117. issues,
  118. code="FLIGHT_TYPE_MISMATCH",
  119. severity="error",
  120. message=(
  121. "Planner选择的航班类型与"
  122. "候选筛选结果不一致。"
  123. ),
  124. )
  125. if selected.flight_type == "round_trip":
  126. combination = next(
  127. (
  128. option
  129. for option
  130. in selection.round_trip_options
  131. if option.combination_id
  132. == selected.combination_id
  133. ),
  134. None,
  135. )
  136. if combination is None:
  137. self._add_issue(
  138. issues,
  139. code=(
  140. "FLIGHT_COMBINATION_NOT_FOUND"
  141. ),
  142. severity="error",
  143. message=(
  144. "Planner选择的往返航班组合"
  145. "不存在于真实候选中:"
  146. f"{selected.combination_id}"
  147. ),
  148. )
  149. return {
  150. "price": None,
  151. "outbound": {},
  152. "return_flight": {},
  153. }
  154. outbound_id = str(
  155. combination.outbound.get(
  156. "option_id",
  157. "",
  158. )
  159. )
  160. return_id = str(
  161. combination.return_flight.get(
  162. "option_id",
  163. "",
  164. )
  165. )
  166. if (
  167. outbound_id
  168. != selected.outbound_option_id
  169. ):
  170. self._add_issue(
  171. issues,
  172. code=(
  173. "OUTBOUND_FLIGHT_ID_MISMATCH"
  174. ),
  175. severity="error",
  176. message=(
  177. "Planner填写的去程航班ID"
  178. "与所选往返组合不一致。"
  179. ),
  180. )
  181. if (
  182. return_id
  183. != selected.return_option_id
  184. ):
  185. self._add_issue(
  186. issues,
  187. code=(
  188. "RETURN_FLIGHT_ID_MISMATCH"
  189. ),
  190. severity="error",
  191. message=(
  192. "Planner填写的返程航班ID"
  193. "与所选往返组合不一致。"
  194. ),
  195. )
  196. if (
  197. selected.quoted_price is not None
  198. and combination.quoted_price
  199. is not None
  200. and not self._numbers_close(
  201. selected.quoted_price,
  202. combination.quoted_price,
  203. )
  204. ):
  205. self._add_issue(
  206. issues,
  207. code="FLIGHT_PRICE_MISMATCH",
  208. severity="warning",
  209. message=(
  210. "Planner填写的航班报价与"
  211. "真实候选接口报价不一致,"
  212. "预算将采用候选接口报价。"
  213. ),
  214. )
  215. return {
  216. "price": combination.quoted_price,
  217. "outbound": combination.outbound,
  218. "return_flight": (
  219. combination.return_flight
  220. ),
  221. }
  222. candidate = next(
  223. (
  224. item
  225. for item
  226. in selection.one_way_options
  227. if item.option_id
  228. == selected.outbound_option_id
  229. ),
  230. None,
  231. )
  232. if candidate is None:
  233. self._add_issue(
  234. issues,
  235. code="ONE_WAY_FLIGHT_NOT_FOUND",
  236. severity="error",
  237. message=(
  238. "Planner选择的单程航班"
  239. "不存在于真实候选中。"
  240. ),
  241. )
  242. return {
  243. "price": None,
  244. "outbound": {},
  245. "return_flight": {},
  246. }
  247. candidate_price = self._to_float(
  248. candidate.flight.get("price")
  249. )
  250. return {
  251. "price": candidate_price,
  252. "outbound": candidate.flight,
  253. "return_flight": {},
  254. }
  255. def _validate_hotel(
  256. self,
  257. *,
  258. request: TravelRequest,
  259. plan: ItineraryPlan,
  260. selection: CandidateSelectionResult,
  261. route_result: RouteEvaluationResult,
  262. issues: list[ValidationIssue],
  263. ) -> dict[str, Any]:
  264. """确认酒店真实存在,并重新计算住宿费用。"""
  265. selected = plan.selected_hotel
  266. evaluated_hotel = next(
  267. (
  268. item
  269. for item
  270. in route_result.evaluated_hotels
  271. if item.hotel_id
  272. == selected.hotel_id
  273. ),
  274. None,
  275. )
  276. ranked_hotel = next(
  277. (
  278. item
  279. for item
  280. in selection.hotels
  281. if item.hotel_id
  282. == selected.hotel_id
  283. ),
  284. None,
  285. )
  286. if (
  287. evaluated_hotel is None
  288. and ranked_hotel is None
  289. ):
  290. self._add_issue(
  291. issues,
  292. code="HOTEL_NOT_FOUND",
  293. severity="error",
  294. message=(
  295. "Planner选择的酒店不存在于"
  296. "真实酒店候选中:"
  297. f"{selected.hotel_id}"
  298. ),
  299. )
  300. return {
  301. "hotel_estimate": None,
  302. "hotel": {},
  303. }
  304. source_hotel = (
  305. evaluated_hotel.hotel
  306. if evaluated_hotel is not None
  307. else ranked_hotel.hotel
  308. )
  309. source_name = str(
  310. source_hotel.get("name", "")
  311. )
  312. if (
  313. source_name
  314. and source_name != selected.name
  315. ):
  316. self._add_issue(
  317. issues,
  318. code="HOTEL_NAME_MISMATCH",
  319. severity="error",
  320. message=(
  321. "Planner填写的酒店名称与"
  322. "所选酒店ID对应名称不一致。"
  323. ),
  324. )
  325. if (
  326. request.departure_date is not None
  327. and selected.check_in_date
  328. != request.departure_date
  329. ):
  330. # 红眼航班(如20:50出发、次日00:05到达)
  331. # 允许入住日期延后一天。
  332. next_day = (
  333. request.departure_date
  334. + timedelta(days=1)
  335. )
  336. if selected.check_in_date != next_day:
  337. self._add_issue(
  338. issues,
  339. code="HOTEL_CHECK_IN_MISMATCH",
  340. severity="error",
  341. message=(
  342. "酒店入住日期必须与"
  343. "旅行出发日期一致;"
  344. "红眼航班最多允许延后一天。"
  345. ),
  346. )
  347. else:
  348. self._add_issue(
  349. issues,
  350. code="HOTEL_CHECK_IN_REDEYE",
  351. severity="warning",
  352. message=(
  353. "酒店入住日期比出发日期晚一天,"
  354. "疑似红眼航班。"
  355. "Validator已接受此日期,"
  356. "但请确认住宿晚数仍正确。"
  357. ),
  358. )
  359. if (
  360. request.return_date is not None
  361. and selected.check_out_date
  362. != request.return_date
  363. ):
  364. self._add_issue(
  365. issues,
  366. code="HOTEL_CHECK_OUT_MISMATCH",
  367. severity="error",
  368. message=(
  369. "酒店退房日期必须与"
  370. "旅行返程日期一致。"
  371. ),
  372. )
  373. expected_nights: int | None = None
  374. if (
  375. request.departure_date is not None
  376. and request.return_date is not None
  377. ):
  378. expected_nights = (
  379. request.return_date
  380. - request.departure_date
  381. ).days
  382. elif request.nights is not None:
  383. expected_nights = request.nights
  384. if (
  385. expected_nights is not None
  386. and selected.nights
  387. != expected_nights
  388. ):
  389. self._add_issue(
  390. issues,
  391. code="HOTEL_NIGHTS_MISMATCH",
  392. severity="error",
  393. message=(
  394. "酒店住宿晚数不正确:"
  395. f"应为{expected_nights}晚,"
  396. f"Planner填写为"
  397. f"{selected.nights}晚。"
  398. ),
  399. )
  400. source_price_per_night = self._to_float(
  401. source_hotel.get(
  402. "price_per_night"
  403. )
  404. )
  405. if source_price_per_night is None:
  406. source_price_per_night = (
  407. selected.price_per_night
  408. )
  409. self._add_issue(
  410. issues,
  411. code="HOTEL_PRICE_SOURCE_MISSING",
  412. severity="warning",
  413. message=(
  414. "酒店候选缺少可解析的"
  415. "每晚价格,预算暂时采用"
  416. "Planner填写的价格。"
  417. ),
  418. )
  419. if (
  420. source_price_per_night is not None
  421. and selected.price_per_night
  422. is not None
  423. and not self._numbers_close(
  424. source_price_per_night,
  425. selected.price_per_night,
  426. )
  427. ):
  428. self._add_issue(
  429. issues,
  430. code=(
  431. "HOTEL_PRICE_PER_NIGHT_MISMATCH"
  432. ),
  433. severity="warning",
  434. message=(
  435. "Planner填写的每晚酒店价格"
  436. "与候选数据不一致,预算将采用"
  437. "候选价格重新计算。"
  438. ),
  439. )
  440. hotel_estimate: float | None = None
  441. if (
  442. source_price_per_night is not None
  443. and expected_nights is not None
  444. ):
  445. hotel_estimate = (
  446. source_price_per_night
  447. * expected_nights
  448. )
  449. if (
  450. hotel_estimate is not None
  451. and selected.estimated_total_price
  452. is not None
  453. and not self._numbers_close(
  454. hotel_estimate,
  455. selected.estimated_total_price,
  456. )
  457. ):
  458. self._add_issue(
  459. issues,
  460. code="HOTEL_TOTAL_PRICE_MISMATCH",
  461. severity="warning",
  462. message=(
  463. "Planner填写的酒店总价估算"
  464. "不正确,已按每晚价格乘"
  465. "住宿晚数重新计算。"
  466. ),
  467. )
  468. if (
  469. route_result.selected_hotel_id
  470. and selected.hotel_id
  471. != route_result.selected_hotel_id
  472. ):
  473. self._add_issue(
  474. issues,
  475. code="NON_TOP_ROUTE_HOTEL",
  476. severity="warning",
  477. message=(
  478. "Planner未选择路线综合排名"
  479. "第一的酒店,需要Reviewer检查"
  480. "选择理由是否充分。"
  481. ),
  482. )
  483. return {
  484. "hotel_estimate": hotel_estimate,
  485. "hotel": source_hotel,
  486. }
  487. def _validate_days(
  488. self,
  489. *,
  490. request: TravelRequest,
  491. plan: ItineraryPlan,
  492. issues: list[ValidationIssue],
  493. ) -> None:
  494. """检查行程日期是否完整且连续。"""
  495. if (
  496. request.departure_date is None
  497. or request.return_date is None
  498. ):
  499. return
  500. expected_day_count = (
  501. request.return_date
  502. - request.departure_date
  503. ).days + 1
  504. expected_dates = [
  505. request.departure_date
  506. + timedelta(days=index)
  507. for index
  508. in range(expected_day_count)
  509. ]
  510. actual_dates = [
  511. day.date
  512. for day in plan.days
  513. ]
  514. actual_indexes = [
  515. day.day_index
  516. for day in plan.days
  517. ]
  518. if len(plan.days) != expected_day_count:
  519. self._add_issue(
  520. issues,
  521. code="DAY_COUNT_MISMATCH",
  522. severity="error",
  523. message=(
  524. "行程天数不正确:"
  525. f"应生成{expected_day_count}天,"
  526. f"实际生成{len(plan.days)}天。"
  527. ),
  528. )
  529. if actual_dates != expected_dates:
  530. missing_dates = [
  531. item.isoformat()
  532. for item in expected_dates
  533. if item not in actual_dates
  534. ]
  535. extra_dates = [
  536. item.isoformat()
  537. for item in actual_dates
  538. if item not in expected_dates
  539. ]
  540. details: list[str] = []
  541. if missing_dates:
  542. details.append(
  543. "缺少日期:"
  544. + "、".join(missing_dates)
  545. )
  546. if extra_dates:
  547. details.append(
  548. "多余日期:"
  549. + "、".join(extra_dates)
  550. )
  551. self._add_issue(
  552. issues,
  553. code="DAY_DATE_MISMATCH",
  554. severity="error",
  555. message=(
  556. "每日行程日期没有完整覆盖"
  557. "旅行期间。"
  558. + (
  559. " ".join(details)
  560. if details
  561. else ""
  562. )
  563. ),
  564. )
  565. expected_indexes = list(
  566. range(1, len(plan.days) + 1)
  567. )
  568. if actual_indexes != expected_indexes:
  569. self._add_issue(
  570. issues,
  571. code="DAY_INDEX_MISMATCH",
  572. severity="error",
  573. message=(
  574. "每日行程的day_index必须"
  575. "从1开始连续递增。"
  576. ),
  577. )
  578. def _validate_activities(
  579. self,
  580. *,
  581. request: TravelRequest,
  582. plan: ItineraryPlan,
  583. map_result: MapResearchResult,
  584. issues: list[ValidationIssue],
  585. ) -> None:
  586. """检查时间冲突、POI真实性和旅行节奏。"""
  587. attractions_by_id = {
  588. place.poi_id: place
  589. for place in map_result.attractions
  590. if place.poi_id
  591. }
  592. attractions_by_name = {
  593. place.name: place
  594. for place in map_result.attractions
  595. }
  596. restaurants_by_id = {
  597. place.poi_id: place
  598. for place in map_result.restaurants
  599. if place.poi_id
  600. }
  601. restaurants_by_name = {
  602. place.name: place
  603. for place in map_result.restaurants
  604. }
  605. used_attractions: set[str] = set()
  606. pace_limits = {
  607. "relaxed": 2,
  608. "normal": 3,
  609. "intensive": 4,
  610. }
  611. attraction_limit = pace_limits.get(
  612. request.pace,
  613. 3,
  614. )
  615. for day in plan.days:
  616. sequences = [
  617. activity.sequence
  618. for activity in day.activities
  619. ]
  620. if len(sequences) != len(set(sequences)):
  621. self._add_issue(
  622. issues,
  623. code=(
  624. "DUPLICATE_ACTIVITY_SEQUENCE"
  625. ),
  626. severity="error",
  627. message=(
  628. f"第{day.day_index}天存在"
  629. "重复的活动sequence。"
  630. ),
  631. day_index=day.day_index,
  632. )
  633. expected_sequences = list(
  634. range(1, len(day.activities) + 1)
  635. )
  636. if sequences != expected_sequences:
  637. self._add_issue(
  638. issues,
  639. code="ACTIVITY_SEQUENCE_GAP",
  640. severity="warning",
  641. message=(
  642. f"第{day.day_index}天的活动"
  643. "sequence没有从1连续递增。"
  644. ),
  645. day_index=day.day_index,
  646. )
  647. ordered_activities = sorted(
  648. day.activities,
  649. key=lambda item: item.start_time,
  650. )
  651. previous: ItineraryActivity | None = None
  652. attraction_count = 0
  653. for activity in ordered_activities:
  654. # 航班、交通类活动允许跨午夜
  655. # (如 20:50->次日00:05)。
  656. if (
  657. activity.end_time
  658. <= activity.start_time
  659. ):
  660. is_cross_midnight = (
  661. activity.activity_type
  662. in {"flight", "transport"}
  663. )
  664. self._add_issue(
  665. issues,
  666. code=(
  667. "INVALID_ACTIVITY_TIME"
  668. ),
  669. severity=(
  670. "warning"
  671. if is_cross_midnight
  672. else "error"
  673. ),
  674. message=(
  675. f"第{day.day_index}天活动"
  676. f"'{activity.name}'的"
  677. "结束时间早于或等于开始时间"
  678. "(跨午夜活动请确认时长正确)。"
  679. if is_cross_midnight
  680. else (
  681. f"第{day.day_index}天活动"
  682. f"'{activity.name}'的"
  683. "结束时间必须晚于开始时间。"
  684. )
  685. ),
  686. day_index=day.day_index,
  687. activity_sequence=(
  688. activity.sequence
  689. ),
  690. )
  691. if (
  692. previous is not None
  693. and activity.start_time
  694. < previous.end_time
  695. # 跨午夜活动的时间比较
  696. # 无法反映实际先后关系,跳过。
  697. and previous.end_time
  698. > previous.start_time
  699. and activity.end_time
  700. > activity.start_time
  701. ):
  702. self._add_issue(
  703. issues,
  704. code="ACTIVITY_OVERLAP",
  705. severity="error",
  706. message=(
  707. f"第{day.day_index}天活动"
  708. f"'{previous.name}'与"
  709. f"'{activity.name}'"
  710. "存在时间重叠。"
  711. ),
  712. day_index=day.day_index,
  713. activity_sequence=(
  714. activity.sequence
  715. ),
  716. )
  717. previous = activity
  718. if (
  719. activity.activity_type
  720. == "attraction"
  721. ):
  722. attraction_count += 1
  723. attraction_key = (
  724. activity.reference_id
  725. or activity.name
  726. )
  727. if attraction_key in used_attractions:
  728. self._add_issue(
  729. issues,
  730. code=(
  731. "DUPLICATE_ATTRACTION"
  732. ),
  733. severity="error",
  734. message=(
  735. f"景点'{activity.name}'"
  736. "在行程中被重复安排。"
  737. ),
  738. day_index=day.day_index,
  739. activity_sequence=(
  740. activity.sequence
  741. ),
  742. )
  743. used_attractions.add(
  744. attraction_key
  745. )
  746. self._validate_place_reference(
  747. activity=activity,
  748. places_by_id=(
  749. attractions_by_id
  750. ),
  751. places_by_name=(
  752. attractions_by_name
  753. ),
  754. place_label="景点",
  755. issues=issues,
  756. day_index=day.day_index,
  757. )
  758. elif (
  759. activity.activity_type
  760. == "restaurant"
  761. ):
  762. self._validate_place_reference(
  763. activity=activity,
  764. places_by_id=(
  765. restaurants_by_id
  766. ),
  767. places_by_name=(
  768. restaurants_by_name
  769. ),
  770. place_label="餐厅",
  771. issues=issues,
  772. day_index=day.day_index,
  773. )
  774. if attraction_count > attraction_limit:
  775. self._add_issue(
  776. issues,
  777. code="PACE_LIMIT_EXCEEDED",
  778. severity="warning",
  779. message=(
  780. f"第{day.day_index}天安排了"
  781. f"{attraction_count}个主要景点,"
  782. f"超过{request.pace}节奏建议的"
  783. f"{attraction_limit}个。"
  784. ),
  785. day_index=day.day_index,
  786. )
  787. def _validate_place_reference(
  788. self,
  789. *,
  790. activity: ItineraryActivity,
  791. places_by_id: dict[str, Any],
  792. places_by_name: dict[str, Any],
  793. place_label: str,
  794. issues: list[ValidationIssue],
  795. day_index: int,
  796. ) -> None:
  797. """确认活动引用的POI来自高德查询结果。"""
  798. if activity.reference_id:
  799. place = places_by_id.get(
  800. activity.reference_id
  801. )
  802. if place is None:
  803. self._add_issue(
  804. issues,
  805. code=f"UNKNOWN_{place_label}_ID",
  806. severity="error",
  807. message=(
  808. f"{place_label}"
  809. f"'{activity.name}'的POI ID"
  810. "不存在于高德查询候选中。"
  811. ),
  812. day_index=day_index,
  813. activity_sequence=(
  814. activity.sequence
  815. ),
  816. )
  817. return
  818. if place.name != activity.name:
  819. self._add_issue(
  820. issues,
  821. code=f"{place_label}_NAME_MISMATCH",
  822. severity="error",
  823. message=(
  824. f"{place_label}POI ID对应名称"
  825. f"为'{place.name}',"
  826. f"Planner却填写为"
  827. f"'{activity.name}'。"
  828. ),
  829. day_index=day_index,
  830. activity_sequence=(
  831. activity.sequence
  832. ),
  833. )
  834. return
  835. if activity.name not in places_by_name:
  836. self._add_issue(
  837. issues,
  838. code=f"UNKNOWN_{place_label}_NAME",
  839. severity="error",
  840. message=(
  841. f"{place_label}"
  842. f"'{activity.name}'"
  843. "不存在于高德查询候选中。"
  844. ),
  845. day_index=day_index,
  846. activity_sequence=(
  847. activity.sequence
  848. ),
  849. )
  850. def _validate_boundary_days(
  851. self,
  852. *,
  853. request: TravelRequest,
  854. plan: ItineraryPlan,
  855. flight_data: dict[str, Any],
  856. issues: list[ValidationIssue],
  857. ) -> None:
  858. """检查到达日和返程日安排是否合理。"""
  859. outbound = flight_data.get(
  860. "outbound",
  861. {},
  862. )
  863. return_flight = flight_data.get(
  864. "return_flight",
  865. {},
  866. )
  867. if not isinstance(outbound, dict):
  868. outbound = {}
  869. if not isinstance(return_flight, dict):
  870. return_flight = {}
  871. outbound_departure = self._parse_datetime(
  872. outbound.get("departure_time")
  873. )
  874. outbound_arrival = self._parse_datetime(
  875. outbound.get("arrival_time")
  876. )
  877. return_departure = self._parse_datetime(
  878. return_flight.get(
  879. "departure_time"
  880. )
  881. )
  882. # 检查去程日期。
  883. if (
  884. outbound_departure is not None
  885. and request.departure_date is not None
  886. and outbound_departure.date()
  887. != request.departure_date
  888. ):
  889. self._add_issue(
  890. issues,
  891. code="OUTBOUND_DATE_MISMATCH",
  892. severity="error",
  893. message=(
  894. "所选去程航班日期与"
  895. "用户出发日期不一致。"
  896. ),
  897. )
  898. # 检查返程日期。
  899. if (
  900. return_departure is not None
  901. and request.return_date is not None
  902. and return_departure.date()
  903. != request.return_date
  904. ):
  905. self._add_issue(
  906. issues,
  907. code="RETURN_DATE_MISMATCH",
  908. severity="error",
  909. message=(
  910. "所选返程航班日期与"
  911. "用户返程日期不一致。"
  912. ),
  913. )
  914. departure_day = next(
  915. (
  916. day
  917. for day in plan.days
  918. if day.date
  919. == request.departure_date
  920. ),
  921. None,
  922. )
  923. return_day = next(
  924. (
  925. day
  926. for day in plan.days
  927. if day.date
  928. == request.return_date
  929. ),
  930. None,
  931. )
  932. if departure_day is not None:
  933. has_outbound_flight = any(
  934. activity.activity_type
  935. == "flight"
  936. for activity
  937. in departure_day.activities
  938. )
  939. if not has_outbound_flight:
  940. self._add_issue(
  941. issues,
  942. code=(
  943. "OUTBOUND_FLIGHT_ACTIVITY_MISSING"
  944. ),
  945. severity="warning",
  946. message=(
  947. "到达日行程中没有明确记录"
  948. "去程航班活动。"
  949. ),
  950. day_index=(
  951. departure_day.day_index
  952. ),
  953. )
  954. if (
  955. departure_day is not None
  956. and outbound_arrival is not None
  957. and outbound_arrival.date()
  958. == departure_day.date
  959. ):
  960. # 默认预留90分钟完成下机、
  961. # 取行李和离开机场。
  962. recommended_ready_time = (
  963. outbound_arrival
  964. + timedelta(minutes=90)
  965. )
  966. for activity in (
  967. departure_day.activities
  968. ):
  969. if activity.activity_type in {
  970. "flight",
  971. "transport",
  972. }:
  973. continue
  974. activity_start = datetime.combine(
  975. departure_day.date,
  976. activity.start_time,
  977. )
  978. if activity_start < outbound_arrival:
  979. self._add_issue(
  980. issues,
  981. code=(
  982. "ACTIVITY_BEFORE_ARRIVAL"
  983. ),
  984. severity="error",
  985. message=(
  986. f"到达日活动"
  987. f"'{activity.name}'"
  988. "安排在航班到达之前。"
  989. ),
  990. day_index=(
  991. departure_day.day_index
  992. ),
  993. activity_sequence=(
  994. activity.sequence
  995. ),
  996. )
  997. elif (
  998. activity_start
  999. < recommended_ready_time
  1000. ):
  1001. self._add_issue(
  1002. issues,
  1003. code=(
  1004. "ARRIVAL_BUFFER_TOO_SHORT"
  1005. ),
  1006. severity="warning",
  1007. message=(
  1008. f"到达日活动"
  1009. f"'{activity.name}'"
  1010. "距离航班到达时间过近,"
  1011. "建议预留下机、行李和"
  1012. "机场离场时间。"
  1013. ),
  1014. day_index=(
  1015. departure_day.day_index
  1016. ),
  1017. activity_sequence=(
  1018. activity.sequence
  1019. ),
  1020. )
  1021. if return_day is not None:
  1022. has_return_flight = any(
  1023. activity.activity_type
  1024. == "flight"
  1025. for activity
  1026. in return_day.activities
  1027. )
  1028. if (
  1029. return_flight
  1030. and not has_return_flight
  1031. ):
  1032. self._add_issue(
  1033. issues,
  1034. code=(
  1035. "RETURN_FLIGHT_ACTIVITY_MISSING"
  1036. ),
  1037. severity="warning",
  1038. message=(
  1039. "返程日行程中没有明确记录"
  1040. "返程航班活动。"
  1041. ),
  1042. day_index=return_day.day_index,
  1043. )
  1044. if (
  1045. return_day is not None
  1046. and return_departure is not None
  1047. and return_departure.date()
  1048. == return_day.date
  1049. ):
  1050. # 默认国内航班提前180分钟结束
  1051. # 景点、餐饮等非交通活动。
  1052. recommended_leave_time = (
  1053. return_departure
  1054. - timedelta(minutes=180)
  1055. )
  1056. for activity in return_day.activities:
  1057. if activity.activity_type in {
  1058. "flight",
  1059. "transport",
  1060. }:
  1061. continue
  1062. activity_end = datetime.combine(
  1063. return_day.date,
  1064. activity.end_time,
  1065. )
  1066. if activity_end > return_departure:
  1067. self._add_issue(
  1068. issues,
  1069. code=(
  1070. "ACTIVITY_AFTER_FLIGHT"
  1071. ),
  1072. severity="error",
  1073. message=(
  1074. f"返程日活动"
  1075. f"'{activity.name}'"
  1076. "结束时间晚于返程航班"
  1077. "起飞时间。"
  1078. ),
  1079. day_index=return_day.day_index,
  1080. activity_sequence=(
  1081. activity.sequence
  1082. ),
  1083. )
  1084. elif (
  1085. activity_end
  1086. > recommended_leave_time
  1087. ):
  1088. self._add_issue(
  1089. issues,
  1090. code=(
  1091. "RETURN_BUFFER_TOO_SHORT"
  1092. ),
  1093. severity="warning",
  1094. message=(
  1095. f"返程日活动"
  1096. f"'{activity.name}'"
  1097. "距离航班起飞时间较近,"
  1098. "可能没有充分预留"
  1099. "前往机场和办理登机的时间。"
  1100. ),
  1101. day_index=return_day.day_index,
  1102. activity_sequence=(
  1103. activity.sequence
  1104. ),
  1105. )
  1106. def _build_budget(
  1107. self,
  1108. *,
  1109. request: TravelRequest,
  1110. plan: ItineraryPlan,
  1111. flight_quote: float | None,
  1112. hotel_estimate: float | None,
  1113. issues: list[ValidationIssue],
  1114. ) -> BudgetEstimate:
  1115. """计算目前能够确定的旅行费用。"""
  1116. explicit_activity_costs = 0.0
  1117. for day in plan.days:
  1118. for activity in day.activities:
  1119. if activity.activity_type in {
  1120. "flight",
  1121. "hotel_check_in",
  1122. "hotel_check_out",
  1123. }:
  1124. # 航班和酒店已经单独统计,
  1125. # 防止重复计费。
  1126. continue
  1127. if activity.estimated_cost is not None:
  1128. explicit_activity_costs += (
  1129. activity.estimated_cost
  1130. )
  1131. known_components: list[float] = []
  1132. if flight_quote is not None:
  1133. known_components.append(flight_quote)
  1134. else:
  1135. self._add_issue(
  1136. issues,
  1137. code="FLIGHT_PRICE_UNKNOWN",
  1138. severity="warning",
  1139. message=(
  1140. "所选航班缺少可解析报价,"
  1141. "无法完整估算预算。"
  1142. ),
  1143. )
  1144. if hotel_estimate is not None:
  1145. known_components.append(
  1146. hotel_estimate
  1147. )
  1148. else:
  1149. self._add_issue(
  1150. issues,
  1151. code="HOTEL_PRICE_UNKNOWN",
  1152. severity="warning",
  1153. message=(
  1154. "所选酒店缺少可解析价格,"
  1155. "无法完整估算预算。"
  1156. ),
  1157. )
  1158. known_components.append(
  1159. explicit_activity_costs
  1160. )
  1161. known_total = (
  1162. round(sum(known_components), 2)
  1163. if known_components
  1164. else None
  1165. )
  1166. user_budget = request.total_budget
  1167. remaining_known_budget: (
  1168. float | None
  1169. ) = None
  1170. status = "unknown"
  1171. if (
  1172. user_budget is not None
  1173. and known_total is not None
  1174. ):
  1175. remaining_known_budget = round(
  1176. user_budget - known_total,
  1177. 2,
  1178. )
  1179. if known_total > user_budget:
  1180. status = "over_budget"
  1181. self._add_issue(
  1182. issues,
  1183. code="KNOWN_BUDGET_EXCEEDED",
  1184. severity="error",
  1185. message=(
  1186. "当前可确定费用已超过"
  1187. f"用户总预算:已知费用"
  1188. f"{known_total:.0f}"
  1189. f"{request.currency},"
  1190. f"预算为"
  1191. f"{user_budget:.0f}"
  1192. f"{request.currency}。"
  1193. ),
  1194. )
  1195. else:
  1196. status = "within_known_budget"
  1197. coverage_note = (
  1198. "当前预算仅包含航班接口报价、"
  1199. "酒店估算和行程中明确填写的活动费用;"
  1200. "未明确查询的餐饮、门票、市内交通、"
  1201. "保险及其他费用未计入。"
  1202. "航班接口报价的计价范围仍以"
  1203. "实际供应商页面为准。"
  1204. )
  1205. return BudgetEstimate(
  1206. currency=request.currency,
  1207. flight_quote=flight_quote,
  1208. hotel_estimate=hotel_estimate,
  1209. explicit_activity_costs=round(
  1210. explicit_activity_costs,
  1211. 2,
  1212. ),
  1213. known_total=known_total,
  1214. user_budget=user_budget,
  1215. remaining_known_budget=(
  1216. remaining_known_budget
  1217. ),
  1218. status=status,
  1219. coverage_note=coverage_note,
  1220. )
  1221. @staticmethod
  1222. def _parse_datetime(
  1223. value: object,
  1224. ) -> datetime | None:
  1225. """安全解析时间字符串,解析失败返回None。"""
  1226. if value is None:
  1227. return None
  1228. if isinstance(value, datetime):
  1229. return value
  1230. try:
  1231. return datetime.fromisoformat(
  1232. str(value)
  1233. )
  1234. except ValueError:
  1235. return None
  1236. @staticmethod
  1237. def _to_float(
  1238. value: object,
  1239. ) -> float | None:
  1240. """安全转换为float,不可转换值返回None。"""
  1241. if value is None:
  1242. return None
  1243. if isinstance(value, bool):
  1244. return None
  1245. try:
  1246. return float(value)
  1247. except (TypeError, ValueError):
  1248. return None
  1249. @staticmethod
  1250. def _numbers_close(
  1251. first: float,
  1252. second: float,
  1253. ) -> bool:
  1254. """允许1元或1%的价格误差。"""
  1255. tolerance = max(
  1256. 1.0,
  1257. abs(second) * 0.01,
  1258. )
  1259. return abs(first - second) <= tolerance
  1260. @staticmethod
  1261. def _add_issue(
  1262. issues: list[ValidationIssue],
  1263. *,
  1264. code: str,
  1265. severity: str,
  1266. message: str,
  1267. day_index: int | None = None,
  1268. activity_sequence: int | None = None,
  1269. ) -> None:
  1270. """按严重级别(error/warning)向问题列表追加一条校验问题。"""
  1271. issues.append(
  1272. ValidationIssue(
  1273. code=code,
  1274. severity=severity,
  1275. message=message,
  1276. day_index=day_index,
  1277. activity_sequence=(
  1278. activity_sequence
  1279. ),
  1280. )
  1281. )