| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442 |
- from __future__ import annotations
- """方案校验服务:用确定性规则校验行程方案的航班/酒店/活动安排/预算合理性,产出结构化问题清单与预算估算。不调用大模型。"""
- from datetime import datetime, timedelta
- from typing import Any
- from app.schemas.itinerary import (
- ItineraryActivity,
- ItineraryPlan,
- )
- from app.schemas.place import (
- MapResearchResult,
- )
- from app.schemas.route import (
- RouteEvaluationResult,
- )
- from app.schemas.selection import (
- CandidateSelectionResult,
- )
- from app.schemas.travel_request import (
- TravelRequest,
- )
- from app.schemas.validation import (
- BudgetEstimate,
- PlanValidationResult,
- ValidationIssue,
- )
- class PlanValidationService:
- """对Planner生成的行程进行确定性校验。
- 不调用大模型,不调用外部API。
- """
- def validate(
- self,
- *,
- request: TravelRequest,
- plan: ItineraryPlan,
- selection: CandidateSelectionResult,
- map_result: MapResearchResult,
- route_result: RouteEvaluationResult,
- ) -> PlanValidationResult:
- """校验完整行程方案:验证航班与酒店ID是否来自候选列表、活动地点是否存在、预算是否合理,返回校验结果与问题清单。"""
- issues: list[ValidationIssue] = []
- flight_data = self._validate_flight(
- plan=plan,
- selection=selection,
- issues=issues,
- )
- hotel_data = self._validate_hotel(
- request=request,
- plan=plan,
- selection=selection,
- route_result=route_result,
- issues=issues,
- )
- self._validate_days(
- request=request,
- plan=plan,
- issues=issues,
- )
- self._validate_activities(
- request=request,
- plan=plan,
- map_result=map_result,
- issues=issues,
- )
- self._validate_boundary_days(
- request=request,
- plan=plan,
- flight_data=flight_data,
- issues=issues,
- )
- budget = self._build_budget(
- request=request,
- plan=plan,
- flight_quote=flight_data.get(
- "price"
- ),
- hotel_estimate=hotel_data.get(
- "hotel_estimate"
- ),
- issues=issues,
- )
- error_issues = [
- issue
- for issue in issues
- if issue.severity == "error"
- ]
- warning_issues = [
- issue
- for issue in issues
- if issue.severity == "warning"
- ]
- revision_feedback = [
- issue.message
- for issue in error_issues
- ]
- return PlanValidationResult(
- is_valid=not error_issues,
- error_count=len(error_issues),
- warning_count=len(warning_issues),
- issues=issues,
- budget=budget,
- revision_feedback=revision_feedback,
- )
- def _validate_flight(
- self,
- *,
- plan: ItineraryPlan,
- selection: CandidateSelectionResult,
- issues: list[ValidationIssue],
- ) -> dict[str, Any]:
- """确认Planner选择的航班来自真实候选。"""
- selected = plan.selected_flight
- if (
- selected.flight_type
- != selection.flight_type
- ):
- self._add_issue(
- issues,
- code="FLIGHT_TYPE_MISMATCH",
- severity="error",
- message=(
- "Planner选择的航班类型与"
- "候选筛选结果不一致。"
- ),
- )
- if selected.flight_type == "round_trip":
- combination = next(
- (
- option
- for option
- in selection.round_trip_options
- if option.combination_id
- == selected.combination_id
- ),
- None,
- )
- if combination is None:
- self._add_issue(
- issues,
- code=(
- "FLIGHT_COMBINATION_NOT_FOUND"
- ),
- severity="error",
- message=(
- "Planner选择的往返航班组合"
- "不存在于真实候选中:"
- f"{selected.combination_id}"
- ),
- )
- return {
- "price": None,
- "outbound": {},
- "return_flight": {},
- }
- outbound_id = str(
- combination.outbound.get(
- "option_id",
- "",
- )
- )
- return_id = str(
- combination.return_flight.get(
- "option_id",
- "",
- )
- )
- if (
- outbound_id
- != selected.outbound_option_id
- ):
- self._add_issue(
- issues,
- code=(
- "OUTBOUND_FLIGHT_ID_MISMATCH"
- ),
- severity="error",
- message=(
- "Planner填写的去程航班ID"
- "与所选往返组合不一致。"
- ),
- )
- if (
- return_id
- != selected.return_option_id
- ):
- self._add_issue(
- issues,
- code=(
- "RETURN_FLIGHT_ID_MISMATCH"
- ),
- severity="error",
- message=(
- "Planner填写的返程航班ID"
- "与所选往返组合不一致。"
- ),
- )
- if (
- selected.quoted_price is not None
- and combination.quoted_price
- is not None
- and not self._numbers_close(
- selected.quoted_price,
- combination.quoted_price,
- )
- ):
- self._add_issue(
- issues,
- code="FLIGHT_PRICE_MISMATCH",
- severity="warning",
- message=(
- "Planner填写的航班报价与"
- "真实候选接口报价不一致,"
- "预算将采用候选接口报价。"
- ),
- )
- return {
- "price": combination.quoted_price,
- "outbound": combination.outbound,
- "return_flight": (
- combination.return_flight
- ),
- }
- candidate = next(
- (
- item
- for item
- in selection.one_way_options
- if item.option_id
- == selected.outbound_option_id
- ),
- None,
- )
- if candidate is None:
- self._add_issue(
- issues,
- code="ONE_WAY_FLIGHT_NOT_FOUND",
- severity="error",
- message=(
- "Planner选择的单程航班"
- "不存在于真实候选中。"
- ),
- )
- return {
- "price": None,
- "outbound": {},
- "return_flight": {},
- }
- candidate_price = self._to_float(
- candidate.flight.get("price")
- )
- return {
- "price": candidate_price,
- "outbound": candidate.flight,
- "return_flight": {},
- }
- def _validate_hotel(
- self,
- *,
- request: TravelRequest,
- plan: ItineraryPlan,
- selection: CandidateSelectionResult,
- route_result: RouteEvaluationResult,
- issues: list[ValidationIssue],
- ) -> dict[str, Any]:
- """确认酒店真实存在,并重新计算住宿费用。"""
- selected = plan.selected_hotel
- evaluated_hotel = next(
- (
- item
- for item
- in route_result.evaluated_hotels
- if item.hotel_id
- == selected.hotel_id
- ),
- None,
- )
- ranked_hotel = next(
- (
- item
- for item
- in selection.hotels
- if item.hotel_id
- == selected.hotel_id
- ),
- None,
- )
- if (
- evaluated_hotel is None
- and ranked_hotel is None
- ):
- self._add_issue(
- issues,
- code="HOTEL_NOT_FOUND",
- severity="error",
- message=(
- "Planner选择的酒店不存在于"
- "真实酒店候选中:"
- f"{selected.hotel_id}"
- ),
- )
- return {
- "hotel_estimate": None,
- "hotel": {},
- }
- source_hotel = (
- evaluated_hotel.hotel
- if evaluated_hotel is not None
- else ranked_hotel.hotel
- )
- source_name = str(
- source_hotel.get("name", "")
- )
- if (
- source_name
- and source_name != selected.name
- ):
- self._add_issue(
- issues,
- code="HOTEL_NAME_MISMATCH",
- severity="error",
- message=(
- "Planner填写的酒店名称与"
- "所选酒店ID对应名称不一致。"
- ),
- )
- if (
- request.departure_date is not None
- and selected.check_in_date
- != request.departure_date
- ):
- # 红眼航班(如20:50出发、次日00:05到达)
- # 允许入住日期延后一天。
- next_day = (
- request.departure_date
- + timedelta(days=1)
- )
- if selected.check_in_date != next_day:
- self._add_issue(
- issues,
- code="HOTEL_CHECK_IN_MISMATCH",
- severity="error",
- message=(
- "酒店入住日期必须与"
- "旅行出发日期一致;"
- "红眼航班最多允许延后一天。"
- ),
- )
- else:
- self._add_issue(
- issues,
- code="HOTEL_CHECK_IN_REDEYE",
- severity="warning",
- message=(
- "酒店入住日期比出发日期晚一天,"
- "疑似红眼航班。"
- "Validator已接受此日期,"
- "但请确认住宿晚数仍正确。"
- ),
- )
- if (
- request.return_date is not None
- and selected.check_out_date
- != request.return_date
- ):
- self._add_issue(
- issues,
- code="HOTEL_CHECK_OUT_MISMATCH",
- severity="error",
- message=(
- "酒店退房日期必须与"
- "旅行返程日期一致。"
- ),
- )
- expected_nights: int | None = None
- if (
- request.departure_date is not None
- and request.return_date is not None
- ):
- expected_nights = (
- request.return_date
- - request.departure_date
- ).days
- elif request.nights is not None:
- expected_nights = request.nights
- if (
- expected_nights is not None
- and selected.nights
- != expected_nights
- ):
- self._add_issue(
- issues,
- code="HOTEL_NIGHTS_MISMATCH",
- severity="error",
- message=(
- "酒店住宿晚数不正确:"
- f"应为{expected_nights}晚,"
- f"Planner填写为"
- f"{selected.nights}晚。"
- ),
- )
- source_price_per_night = self._to_float(
- source_hotel.get(
- "price_per_night"
- )
- )
- if source_price_per_night is None:
- source_price_per_night = (
- selected.price_per_night
- )
- self._add_issue(
- issues,
- code="HOTEL_PRICE_SOURCE_MISSING",
- severity="warning",
- message=(
- "酒店候选缺少可解析的"
- "每晚价格,预算暂时采用"
- "Planner填写的价格。"
- ),
- )
- if (
- source_price_per_night is not None
- and selected.price_per_night
- is not None
- and not self._numbers_close(
- source_price_per_night,
- selected.price_per_night,
- )
- ):
- self._add_issue(
- issues,
- code=(
- "HOTEL_PRICE_PER_NIGHT_MISMATCH"
- ),
- severity="warning",
- message=(
- "Planner填写的每晚酒店价格"
- "与候选数据不一致,预算将采用"
- "候选价格重新计算。"
- ),
- )
- hotel_estimate: float | None = None
- if (
- source_price_per_night is not None
- and expected_nights is not None
- ):
- hotel_estimate = (
- source_price_per_night
- * expected_nights
- )
- if (
- hotel_estimate is not None
- and selected.estimated_total_price
- is not None
- and not self._numbers_close(
- hotel_estimate,
- selected.estimated_total_price,
- )
- ):
- self._add_issue(
- issues,
- code="HOTEL_TOTAL_PRICE_MISMATCH",
- severity="warning",
- message=(
- "Planner填写的酒店总价估算"
- "不正确,已按每晚价格乘"
- "住宿晚数重新计算。"
- ),
- )
- if (
- route_result.selected_hotel_id
- and selected.hotel_id
- != route_result.selected_hotel_id
- ):
- self._add_issue(
- issues,
- code="NON_TOP_ROUTE_HOTEL",
- severity="warning",
- message=(
- "Planner未选择路线综合排名"
- "第一的酒店,需要Reviewer检查"
- "选择理由是否充分。"
- ),
- )
- return {
- "hotel_estimate": hotel_estimate,
- "hotel": source_hotel,
- }
- def _validate_days(
- self,
- *,
- request: TravelRequest,
- plan: ItineraryPlan,
- issues: list[ValidationIssue],
- ) -> None:
- """检查行程日期是否完整且连续。"""
- if (
- request.departure_date is None
- or request.return_date is None
- ):
- return
- expected_day_count = (
- request.return_date
- - request.departure_date
- ).days + 1
- expected_dates = [
- request.departure_date
- + timedelta(days=index)
- for index
- in range(expected_day_count)
- ]
- actual_dates = [
- day.date
- for day in plan.days
- ]
- actual_indexes = [
- day.day_index
- for day in plan.days
- ]
- if len(plan.days) != expected_day_count:
- self._add_issue(
- issues,
- code="DAY_COUNT_MISMATCH",
- severity="error",
- message=(
- "行程天数不正确:"
- f"应生成{expected_day_count}天,"
- f"实际生成{len(plan.days)}天。"
- ),
- )
- if actual_dates != expected_dates:
- missing_dates = [
- item.isoformat()
- for item in expected_dates
- if item not in actual_dates
- ]
- extra_dates = [
- item.isoformat()
- for item in actual_dates
- if item not in expected_dates
- ]
- details: list[str] = []
- if missing_dates:
- details.append(
- "缺少日期:"
- + "、".join(missing_dates)
- )
- if extra_dates:
- details.append(
- "多余日期:"
- + "、".join(extra_dates)
- )
- self._add_issue(
- issues,
- code="DAY_DATE_MISMATCH",
- severity="error",
- message=(
- "每日行程日期没有完整覆盖"
- "旅行期间。"
- + (
- " ".join(details)
- if details
- else ""
- )
- ),
- )
- expected_indexes = list(
- range(1, len(plan.days) + 1)
- )
- if actual_indexes != expected_indexes:
- self._add_issue(
- issues,
- code="DAY_INDEX_MISMATCH",
- severity="error",
- message=(
- "每日行程的day_index必须"
- "从1开始连续递增。"
- ),
- )
- def _validate_activities(
- self,
- *,
- request: TravelRequest,
- plan: ItineraryPlan,
- map_result: MapResearchResult,
- issues: list[ValidationIssue],
- ) -> None:
- """检查时间冲突、POI真实性和旅行节奏。"""
- attractions_by_id = {
- place.poi_id: place
- for place in map_result.attractions
- if place.poi_id
- }
- attractions_by_name = {
- place.name: place
- for place in map_result.attractions
- }
- restaurants_by_id = {
- place.poi_id: place
- for place in map_result.restaurants
- if place.poi_id
- }
- restaurants_by_name = {
- place.name: place
- for place in map_result.restaurants
- }
- used_attractions: set[str] = set()
- pace_limits = {
- "relaxed": 2,
- "normal": 3,
- "intensive": 4,
- }
- attraction_limit = pace_limits.get(
- request.pace,
- 3,
- )
- for day in plan.days:
- sequences = [
- activity.sequence
- for activity in day.activities
- ]
- if len(sequences) != len(set(sequences)):
- self._add_issue(
- issues,
- code=(
- "DUPLICATE_ACTIVITY_SEQUENCE"
- ),
- severity="error",
- message=(
- f"第{day.day_index}天存在"
- "重复的活动sequence。"
- ),
- day_index=day.day_index,
- )
- expected_sequences = list(
- range(1, len(day.activities) + 1)
- )
- if sequences != expected_sequences:
- self._add_issue(
- issues,
- code="ACTIVITY_SEQUENCE_GAP",
- severity="warning",
- message=(
- f"第{day.day_index}天的活动"
- "sequence没有从1连续递增。"
- ),
- day_index=day.day_index,
- )
- ordered_activities = sorted(
- day.activities,
- key=lambda item: item.start_time,
- )
- previous: ItineraryActivity | None = None
- attraction_count = 0
- for activity in ordered_activities:
- # 航班、交通类活动允许跨午夜
- # (如 20:50->次日00:05)。
- if (
- activity.end_time
- <= activity.start_time
- ):
- is_cross_midnight = (
- activity.activity_type
- in {"flight", "transport"}
- )
- self._add_issue(
- issues,
- code=(
- "INVALID_ACTIVITY_TIME"
- ),
- severity=(
- "warning"
- if is_cross_midnight
- else "error"
- ),
- message=(
- f"第{day.day_index}天活动"
- f"'{activity.name}'的"
- "结束时间早于或等于开始时间"
- "(跨午夜活动请确认时长正确)。"
- if is_cross_midnight
- else (
- f"第{day.day_index}天活动"
- f"'{activity.name}'的"
- "结束时间必须晚于开始时间。"
- )
- ),
- day_index=day.day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- if (
- previous is not None
- and activity.start_time
- < previous.end_time
- # 跨午夜活动的时间比较
- # 无法反映实际先后关系,跳过。
- and previous.end_time
- > previous.start_time
- and activity.end_time
- > activity.start_time
- ):
- self._add_issue(
- issues,
- code="ACTIVITY_OVERLAP",
- severity="error",
- message=(
- f"第{day.day_index}天活动"
- f"'{previous.name}'与"
- f"'{activity.name}'"
- "存在时间重叠。"
- ),
- day_index=day.day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- previous = activity
- if (
- activity.activity_type
- == "attraction"
- ):
- attraction_count += 1
- attraction_key = (
- activity.reference_id
- or activity.name
- )
- if attraction_key in used_attractions:
- self._add_issue(
- issues,
- code=(
- "DUPLICATE_ATTRACTION"
- ),
- severity="error",
- message=(
- f"景点'{activity.name}'"
- "在行程中被重复安排。"
- ),
- day_index=day.day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- used_attractions.add(
- attraction_key
- )
- self._validate_place_reference(
- activity=activity,
- places_by_id=(
- attractions_by_id
- ),
- places_by_name=(
- attractions_by_name
- ),
- place_label="景点",
- issues=issues,
- day_index=day.day_index,
- )
- elif (
- activity.activity_type
- == "restaurant"
- ):
- self._validate_place_reference(
- activity=activity,
- places_by_id=(
- restaurants_by_id
- ),
- places_by_name=(
- restaurants_by_name
- ),
- place_label="餐厅",
- issues=issues,
- day_index=day.day_index,
- )
- if attraction_count > attraction_limit:
- self._add_issue(
- issues,
- code="PACE_LIMIT_EXCEEDED",
- severity="warning",
- message=(
- f"第{day.day_index}天安排了"
- f"{attraction_count}个主要景点,"
- f"超过{request.pace}节奏建议的"
- f"{attraction_limit}个。"
- ),
- day_index=day.day_index,
- )
- def _validate_place_reference(
- self,
- *,
- activity: ItineraryActivity,
- places_by_id: dict[str, Any],
- places_by_name: dict[str, Any],
- place_label: str,
- issues: list[ValidationIssue],
- day_index: int,
- ) -> None:
- """确认活动引用的POI来自高德查询结果。"""
- if activity.reference_id:
- place = places_by_id.get(
- activity.reference_id
- )
- if place is None:
- self._add_issue(
- issues,
- code=f"UNKNOWN_{place_label}_ID",
- severity="error",
- message=(
- f"{place_label}"
- f"'{activity.name}'的POI ID"
- "不存在于高德查询候选中。"
- ),
- day_index=day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- return
- if place.name != activity.name:
- self._add_issue(
- issues,
- code=f"{place_label}_NAME_MISMATCH",
- severity="error",
- message=(
- f"{place_label}POI ID对应名称"
- f"为'{place.name}',"
- f"Planner却填写为"
- f"'{activity.name}'。"
- ),
- day_index=day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- return
- if activity.name not in places_by_name:
- self._add_issue(
- issues,
- code=f"UNKNOWN_{place_label}_NAME",
- severity="error",
- message=(
- f"{place_label}"
- f"'{activity.name}'"
- "不存在于高德查询候选中。"
- ),
- day_index=day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- def _validate_boundary_days(
- self,
- *,
- request: TravelRequest,
- plan: ItineraryPlan,
- flight_data: dict[str, Any],
- issues: list[ValidationIssue],
- ) -> None:
- """检查到达日和返程日安排是否合理。"""
- outbound = flight_data.get(
- "outbound",
- {},
- )
- return_flight = flight_data.get(
- "return_flight",
- {},
- )
- if not isinstance(outbound, dict):
- outbound = {}
- if not isinstance(return_flight, dict):
- return_flight = {}
- outbound_departure = self._parse_datetime(
- outbound.get("departure_time")
- )
- outbound_arrival = self._parse_datetime(
- outbound.get("arrival_time")
- )
- return_departure = self._parse_datetime(
- return_flight.get(
- "departure_time"
- )
- )
- # 检查去程日期。
- if (
- outbound_departure is not None
- and request.departure_date is not None
- and outbound_departure.date()
- != request.departure_date
- ):
- self._add_issue(
- issues,
- code="OUTBOUND_DATE_MISMATCH",
- severity="error",
- message=(
- "所选去程航班日期与"
- "用户出发日期不一致。"
- ),
- )
- # 检查返程日期。
- if (
- return_departure is not None
- and request.return_date is not None
- and return_departure.date()
- != request.return_date
- ):
- self._add_issue(
- issues,
- code="RETURN_DATE_MISMATCH",
- severity="error",
- message=(
- "所选返程航班日期与"
- "用户返程日期不一致。"
- ),
- )
- departure_day = next(
- (
- day
- for day in plan.days
- if day.date
- == request.departure_date
- ),
- None,
- )
- return_day = next(
- (
- day
- for day in plan.days
- if day.date
- == request.return_date
- ),
- None,
- )
- if departure_day is not None:
- has_outbound_flight = any(
- activity.activity_type
- == "flight"
- for activity
- in departure_day.activities
- )
- if not has_outbound_flight:
- self._add_issue(
- issues,
- code=(
- "OUTBOUND_FLIGHT_ACTIVITY_MISSING"
- ),
- severity="warning",
- message=(
- "到达日行程中没有明确记录"
- "去程航班活动。"
- ),
- day_index=(
- departure_day.day_index
- ),
- )
- if (
- departure_day is not None
- and outbound_arrival is not None
- and outbound_arrival.date()
- == departure_day.date
- ):
- # 默认预留90分钟完成下机、
- # 取行李和离开机场。
- recommended_ready_time = (
- outbound_arrival
- + timedelta(minutes=90)
- )
- for activity in (
- departure_day.activities
- ):
- if activity.activity_type in {
- "flight",
- "transport",
- }:
- continue
- activity_start = datetime.combine(
- departure_day.date,
- activity.start_time,
- )
- if activity_start < outbound_arrival:
- self._add_issue(
- issues,
- code=(
- "ACTIVITY_BEFORE_ARRIVAL"
- ),
- severity="error",
- message=(
- f"到达日活动"
- f"'{activity.name}'"
- "安排在航班到达之前。"
- ),
- day_index=(
- departure_day.day_index
- ),
- activity_sequence=(
- activity.sequence
- ),
- )
- elif (
- activity_start
- < recommended_ready_time
- ):
- self._add_issue(
- issues,
- code=(
- "ARRIVAL_BUFFER_TOO_SHORT"
- ),
- severity="warning",
- message=(
- f"到达日活动"
- f"'{activity.name}'"
- "距离航班到达时间过近,"
- "建议预留下机、行李和"
- "机场离场时间。"
- ),
- day_index=(
- departure_day.day_index
- ),
- activity_sequence=(
- activity.sequence
- ),
- )
- if return_day is not None:
- has_return_flight = any(
- activity.activity_type
- == "flight"
- for activity
- in return_day.activities
- )
- if (
- return_flight
- and not has_return_flight
- ):
- self._add_issue(
- issues,
- code=(
- "RETURN_FLIGHT_ACTIVITY_MISSING"
- ),
- severity="warning",
- message=(
- "返程日行程中没有明确记录"
- "返程航班活动。"
- ),
- day_index=return_day.day_index,
- )
- if (
- return_day is not None
- and return_departure is not None
- and return_departure.date()
- == return_day.date
- ):
- # 默认国内航班提前180分钟结束
- # 景点、餐饮等非交通活动。
- recommended_leave_time = (
- return_departure
- - timedelta(minutes=180)
- )
- for activity in return_day.activities:
- if activity.activity_type in {
- "flight",
- "transport",
- }:
- continue
- activity_end = datetime.combine(
- return_day.date,
- activity.end_time,
- )
- if activity_end > return_departure:
- self._add_issue(
- issues,
- code=(
- "ACTIVITY_AFTER_FLIGHT"
- ),
- severity="error",
- message=(
- f"返程日活动"
- f"'{activity.name}'"
- "结束时间晚于返程航班"
- "起飞时间。"
- ),
- day_index=return_day.day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- elif (
- activity_end
- > recommended_leave_time
- ):
- self._add_issue(
- issues,
- code=(
- "RETURN_BUFFER_TOO_SHORT"
- ),
- severity="warning",
- message=(
- f"返程日活动"
- f"'{activity.name}'"
- "距离航班起飞时间较近,"
- "可能没有充分预留"
- "前往机场和办理登机的时间。"
- ),
- day_index=return_day.day_index,
- activity_sequence=(
- activity.sequence
- ),
- )
- def _build_budget(
- self,
- *,
- request: TravelRequest,
- plan: ItineraryPlan,
- flight_quote: float | None,
- hotel_estimate: float | None,
- issues: list[ValidationIssue],
- ) -> BudgetEstimate:
- """计算目前能够确定的旅行费用。"""
- explicit_activity_costs = 0.0
- for day in plan.days:
- for activity in day.activities:
- if activity.activity_type in {
- "flight",
- "hotel_check_in",
- "hotel_check_out",
- }:
- # 航班和酒店已经单独统计,
- # 防止重复计费。
- continue
- if activity.estimated_cost is not None:
- explicit_activity_costs += (
- activity.estimated_cost
- )
- known_components: list[float] = []
- if flight_quote is not None:
- known_components.append(flight_quote)
- else:
- self._add_issue(
- issues,
- code="FLIGHT_PRICE_UNKNOWN",
- severity="warning",
- message=(
- "所选航班缺少可解析报价,"
- "无法完整估算预算。"
- ),
- )
- if hotel_estimate is not None:
- known_components.append(
- hotel_estimate
- )
- else:
- self._add_issue(
- issues,
- code="HOTEL_PRICE_UNKNOWN",
- severity="warning",
- message=(
- "所选酒店缺少可解析价格,"
- "无法完整估算预算。"
- ),
- )
- known_components.append(
- explicit_activity_costs
- )
- known_total = (
- round(sum(known_components), 2)
- if known_components
- else None
- )
- user_budget = request.total_budget
- remaining_known_budget: (
- float | None
- ) = None
- status = "unknown"
- if (
- user_budget is not None
- and known_total is not None
- ):
- remaining_known_budget = round(
- user_budget - known_total,
- 2,
- )
- if known_total > user_budget:
- status = "over_budget"
- self._add_issue(
- issues,
- code="KNOWN_BUDGET_EXCEEDED",
- severity="error",
- message=(
- "当前可确定费用已超过"
- f"用户总预算:已知费用"
- f"{known_total:.0f}"
- f"{request.currency},"
- f"预算为"
- f"{user_budget:.0f}"
- f"{request.currency}。"
- ),
- )
- else:
- status = "within_known_budget"
- coverage_note = (
- "当前预算仅包含航班接口报价、"
- "酒店估算和行程中明确填写的活动费用;"
- "未明确查询的餐饮、门票、市内交通、"
- "保险及其他费用未计入。"
- "航班接口报价的计价范围仍以"
- "实际供应商页面为准。"
- )
- return BudgetEstimate(
- currency=request.currency,
- flight_quote=flight_quote,
- hotel_estimate=hotel_estimate,
- explicit_activity_costs=round(
- explicit_activity_costs,
- 2,
- ),
- known_total=known_total,
- user_budget=user_budget,
- remaining_known_budget=(
- remaining_known_budget
- ),
- status=status,
- coverage_note=coverage_note,
- )
- @staticmethod
- def _parse_datetime(
- value: object,
- ) -> datetime | None:
- """安全解析时间字符串,解析失败返回None。"""
- if value is None:
- return None
- if isinstance(value, datetime):
- return value
- try:
- return datetime.fromisoformat(
- str(value)
- )
- except ValueError:
- return None
- @staticmethod
- def _to_float(
- value: object,
- ) -> float | None:
- """安全转换为float,不可转换值返回None。"""
- if value is None:
- return None
- if isinstance(value, bool):
- return None
- try:
- return float(value)
- except (TypeError, ValueError):
- return None
- @staticmethod
- def _numbers_close(
- first: float,
- second: float,
- ) -> bool:
- """允许1元或1%的价格误差。"""
- tolerance = max(
- 1.0,
- abs(second) * 0.01,
- )
- return abs(first - second) <= tolerance
- @staticmethod
- def _add_issue(
- issues: list[ValidationIssue],
- *,
- code: str,
- severity: str,
- message: str,
- day_index: int | None = None,
- activity_sequence: int | None = None,
- ) -> None:
- """按严重级别(error/warning)向问题列表追加一条校验问题。"""
- issues.append(
- ValidationIssue(
- code=code,
- severity=severity,
- message=message,
- day_index=day_index,
- activity_sequence=(
- activity_sequence
- ),
- )
- )
|