from typing import Literal from fastapi.params import Depends from typing_extensions import Annotated from loguru import logger from bisect import bisect_left from fastapi import HTTPException from app.client.futabus_client import FutabusClient from app.repositories.trip_repository import TripRepository from app.resolvers.location_resolver import LocationResolver from app.dto.request import ( TicketRequest, TripQueryRequest, ) from app.dto.trip import TripDTO, TripOptionDTO from app.services.helper import ( extra_time_dialogflow, get_time_range, get_weekday_name, to_datetime_from_Dialogflow, ) from app.dto.response import DialogServiceResult class TripService: """Domain service xử lý logic tìm kiếm chuyến xe cho Dialogflow chatbot.""" def __init__( self, location_resolver: LocationResolver, trip_repository: TripRepository, futabus_client: FutabusClient, ): self.location_resolver = location_resolver self.futabus_client = futabus_client self.trip_repository = trip_repository @staticmethod def find_trip_by_time_offices( trips: list[dict], departure_time: str, origin_office_id: int, dest_office_id: int, ) -> dict: """ Tìm chuyến xe phù hợp với thời gian khởi hành và ID văn phòng đón/trả. Args: trips (list[dict]): Danh sách chuyến xe từ API departure_time (str): Thời gian khởi hành (định dạng raw từ API) origin_office_id (int): ID văn phòng điểm đi dest_office_id (int): ID văn phòng điểm đến Returns: dict: Thông tin chuyến xe phù hợp, hoặc {} nếu không tìm thấy. """ if not departure_time or not origin_office_id or not dest_office_id: return {} for trip in trips: route = trip.get("route", {}) if ( trip.get("raw_departure_time") == departure_time and route.get("origin_hub_office_id") == origin_office_id and route.get("dest_hub_office_id") == dest_office_id ): return trip return {} @staticmethod def confirm_trip_search_input(data: TripQueryRequest): """ Xác nhận thông tin tìm kiếm chuyến xe từ người dùng. Args: data (TripQueryRequest): Thông tin tìm kiếm chuyến xe từ người dùng. Returns: DialogServiceResult: Kết quả xác nhận bao gồm thông tin chuyến xe đã tìm kiếm. """ if not data: logger.error("Invalid trip query data") return DialogServiceResult( text=["Thông tin tìm kiếm chuyến xe không hợp lệ."] ) time = extra_time_dialogflow(data.time_select) date = to_datetime_from_Dialogflow(data.date) date, week_day = get_weekday_name(date) text = f"**Điểm đi:** {data.origin_office or data.departure_city}\n" text += f"**Điểm đến:** {data.dest_office or data.destination_city}\n" text += f"**Thời gian:** {time} {date} {week_day}\n" text += f"**Số vé:** {int(data.ticket_number) or 1}\n" options = [ "Tìm chuyến xe", "Không, cảm ơn", ] return DialogServiceResult(text=[text], options=options) @staticmethod def find_surrounding_trips( trips: list[dict], target_time: str, limit: int = 4 ) -> list[dict]: """ Trả về danh sách chuyến xe gần nhất xung quanh thời gian chỉ định. Args: trips (list[dict]): Danh sách chuyến xe (đã được sort theo thời gian) target_time (str): Thời gian muốn so sánh (định dạng giống raw_departure_time) limit (int): Số lượng chuyến cần lấy xung quanh (mặc định: 4) Returns: list[dict]: Các chuyến gần nhất xung quanh thời gian """ if not target_time or not trips or limit <= 0: return [] time_list = [trip.get("raw_departure_time", "") for trip in trips] index = bisect_left(time_list, target_time) half = limit // 2 start = max(0, index - half) end = min(len(trips), start + limit) if end - start < limit: start = max(0, end - limit) return trips[start:end] async def get_all_trip_data(self, data: TripQueryRequest) -> list: """ Lấy tất cả dữ liệu chuyến xe dựa trên thông tin truy vấn từ người dùng. Args: data (TripQueryRequest): Thông tin truy vấn chuyến đi bao gồm văn phòng, thành phố, ngày, số vé, thời gian dự kiến. Returns: DialogServiceResult: Kết quả chứa danh sách chuyến xe hoặc thông báo lỗi. """ ticket_count = data.ticket_number or 1 # Lấy mã và ID các điểm đi/đến origin_id, origin_code, origin_ids = await self.location_resolver.resolve_info( office=data.origin_office, city=data.departure_city, role="origin" ) dest_id, dest_code, dest_ids = await self.location_resolver.resolve_info( office=data.dest_office, city=data.destination_city, role="dest" ) route_ids = await self.trip_repository.get_all_route_ids( origin_code=origin_code, from_id=origin_id, origin_ids=origin_ids, dest_code=dest_code, to_id=dest_id, dest_ids=dest_ids, ) date = to_datetime_from_Dialogflow(data.date) from_time, to_time = get_time_range(date) trips = await self.trip_repository.get_trip_list( from_time, to_time, route_ids, ticket_count ) return trips async def get_trips_option(self, data: TripQueryRequest) -> DialogServiceResult: """ Xử lý truy vấn tìm kiếm chuyến xe theo thông tin người dùng từ Dialogflow. Args: data (TripRequest): Thông tin truy vấn chuyến đi bao gồm văn phòng, thành phố, ngày, số vé, thời gian dự kiến. Returns: DialogflowResult: Dữ liệu trả về cho chatbot Dialogflow bao gồm gợi ý chuyến xe hoặc thông báo lỗi. """ time = extra_time_dialogflow(data.time_select) # ❗ Nếu thời gian không rõ ràng hoặc chưa được xác định if isinstance(time, list) or time is None: return DialogServiceResult(parameters={"is_time_ambiguous": True}) ticket_count = data.ticket_number or 1 # Lấy mã và ID các điểm đi/đến origin_id, origin_code, origin_ids = await self.location_resolver.resolve_info( office=data.origin_office, city=data.departure_city, role="origin" ) dest_id, dest_code, dest_ids = await self.location_resolver.resolve_info( office=data.dest_office, city=data.destination_city, role="dest" ) # route_ids = await self.trip_repository.get_all_route_ids( # origin_code=origin_code, # from_id=origin_id, # origin_ids=origin_ids, # dest_code=dest_code, # to_id=dest_id, # dest_ids=dest_ids, # ) # date = to_datetime_from_Dialogflow( # data.date # ) # Chuyển đổi ngày từ Dialogflow về datetime # # Tính khoảng thời gian tìm kiếm # from_time, to_time = get_time_range(date) trips = await self.get_all_trip_data(data=data) if not trips: text = ( f"Hệ thống không tìm thấy tuyến xe **{data.departure_city}** - " f"**{data.destination_city}**.\nQuý khách vui lòng thử lại hoặc gọi 1900 6067 để được hỗ trợ." ) options = ["Xem tuyến xe khác", "Không, cảm ơn"] return DialogServiceResult( text=[text], payload=options, ) # Nếu có chuyến đúng văn phòng đúng giờ if data.origin_office and data.dest_office: matched = self.find_trip_by_time_offices(trips, origin_ids, dest_ids) if matched: parameters = {"is_valid_trip": True, "trip_data": matched} return DialogServiceResult(parameters=parameters) surrounding_trip = self.find_surrounding_trips( trips=trips, target_time=time, limit=4 ) trip_options: list[TripOptionDTO] = [ TripOptionDTO( trip_id=trip["id"], route=f' {trip["id"]}| {trip["raw_departure_time"]} | {trip["route"]["origin_hub_name"]} => {trip["route"]["dest_hub_name"]}', ) for trip in surrounding_trip if trip["empty_seat_quantity"] >= ticket_count ] prompt = "Quý khách vui lòng lựa chọn chuyến xe\n" + "\n".join( f"{i+1}. {t.route}" for i, t in enumerate(trip_options) ) parameters = {"trip_options": trip_options} return DialogServiceResult( text=[prompt], payload=trip_options, parameters=parameters ) @staticmethod def find_trip_by_route_name( route_name: str, options: list[TripOptionDTO] ) -> TripOptionDTO | None: """ Tìm trip tương ứng với route name (case-insensitive). """ route_name = route_name.strip().lower() return next( (opt for opt in options if opt.route.strip().lower() == route_name), None ) async def validate_trip_selection( self, id_selected: int, data: TripQueryRequest, ) -> DialogServiceResult: """ Xác thực lựa chọn chuyến xe từ người dùng. Args: data (TripValidateRequest): Thông tin lựa chọn chuyến xe từ người dùng. Returns: DialogServiceResult: Kết quả xác thực bao gồm thông tin chuyến xe đã chọn. """ if not id_selected: logger.error("Invalid trip selection data") # raise HTTPException(status_code=400, detail="Invalid trip selection data") # text = "Không có chuyến xe nào được chọn" parameters = {"is_valid_trip": False} return DialogServiceResult(parameters=parameters) matched_trip = await self.find_trip_by_id(trip_id=id_selected, data=data) if not matched_trip: parameters = {"is_valid_trip": False} logger.error("Not found match trip") return DialogServiceResult(parameters=parameters) return DialogServiceResult( parameters={"is_valid_trip": True, "trip_data": matched_trip} ) async def find_trip_by_id(self, trip_id: str, data: TripQueryRequest): if not trip_id: logger.error("Invalid trip selected ID for fetching trip data") return None trips = await self.get_all_trip_data(data) if not trips: logger.error(f"No trips found for trip ID {trip_id}") return None return next((trip for trip in trips if trip.get("id") == trip_id), None) async def get_available_seat(self, trip: TripDTO) -> DialogServiceResult: """ Lấy danh sách ghế trống của chuyến xe. Args: trip (TripSelectRequest): Thông tin chuyến xe cần lấy ghế. Returns: DialogServiceResult: Danh sách ghế trống của chuyến xe. """ if not trip: logger.error("Invalid trip data for fetching seats") raise HTTPException( status_code=400, detail="Invalid trip data for fetching seats" ) seats = await self.futabus_client.get_seat_trip(trip) if not seats: logger.error(f"No seats found for trip {trip.id}") text = "Không có ghế trống cho chuyến xe này" return DialogServiceResult(text=[text]) logger.debug(f"Found {len(seats)} seats for trip {trip.id}") available_seats = [s for s in seats if s.get("bookStatus") == 0] if not available_seats: logger.error(f"No available seats for trip {trip.id}") text = "Không còn ghế trống cho chuyến xe này" return DialogServiceResult(text=[text]) available_seats.sort(key=lambda x: x.get("chair", "")) text = f"Chuyến xe {trip.route.name} có {len(available_seats)} ghế trống" opts = available_seats return DialogServiceResult(text=[text], options=opts) async def validate_seat_selection( self, selected: str, data: TripDTO ) -> DialogServiceResult: """ Xác thực lựa chọn ghế từ người dùng. Args: selected (str): Ghế được người dùng chọn. data (TripDTO): Thông tin chuyến xe. Returns: DialogServiceResult: Kết quả xác thực bao gồm thông tin ghế đã chọn. """ if not selected or not data or not data.id: logger.error("Invalid seat selection data") return DialogServiceResult(parameters={"is_valid_seat": False}) seats = await self.futabus_client.get_seat_trip(data) available_seats = [s for s in seats if s.get("bookStatus") == 0] # Kiểm tra xem ghế có trong danh sách ghế của chuyến xe không if selected.lower() in [s.get("chair").lower() for s in available_seats]: text = f"Bạn đã chọn ghế {selected.upper()} cho chuyến xe." params = {"seat": selected.upper(), "is_valid_seat": True} return DialogServiceResult( text=[text], parameters=params, ) logger.debug(f"Seat {selected} is not available for trip {data.id}") text = ( f"Ghế {selected.upper()} không có sẵn cho chuyến xe. " "Vui lòng chọn ghế khác." ) params = {"is_valid_seat": False} return DialogServiceResult(text=[text], parameters=params) async def get_pick_up_points( self, route_id: int, way_id: int ) -> DialogServiceResult: """ Lấy danh sách các điểm dừng của chuyến xe theo route_id và way_id. Args: route_id (int): ID của tuyến xe. way_id (int): ID của lộ trình. Returns: list[dict]: Danh sách các điểm dừng. """ pickups = await self.trip_repository.get_stop(route_id, way_id, role="pick-up") logger.info( f"Retrieved {len(pickups)} pick-up stops for route {route_id} and way {way_id} : {pickups}" ) if not pickups: logger.error(f"No stops found for route {route_id} and way {way_id}") raise HTTPException( status_code=404, detail=f"No pick-up stops found for route {route_id} and way {way_id}", ) text = "Quý khách vui lòng chọn điểm đón cho chuyến xe của mình" text += "\n" + " | ".join(f"{s['id']}" for i, s in enumerate(pickups)) params = {"pickup_points": pickups} return DialogServiceResult(text=[text], options=pickups, parameters=params) async def validate_stop_selection( self, id_selected: int, data: TripDTO, role: Literal["pick-up", "drop-off"] ) -> DialogServiceResult: """ Xác thực lựa chọn điểm đón của người dùng. Args: selected (str): Điểm đón được người dùng chọn. data (TripDTO): Thông tin chuyến xe. Returns: DialogServiceResult: Kết quả xác thực bao gồm thông tin điểm đón đã chọn. """ logger.info(f"ID: {id_selected}") logger.info(f"Role: {role}") if role == "pick-up": if not id_selected: logger.error("Invalid pick-up selection data") return DialogServiceResult(parameters={"is_valid_pickup": False}) pickups = await self.trip_repository.get_stop( data.route_id, data.way_id, role="pick-up" ) matched_pickup = next( (s for s in pickups if s.get("id") == id_selected), None ) if matched_pickup: pickup_name = matched_pickup.get("name") text = f"Bạn đã chọn điểm đón **{pickup_name}** cho chuyến xe." params = {"pickup": pickup_name, "is_valid_pickup": True} return DialogServiceResult( text=[text], parameters=params, ) logger.info( f"Pick-up point {id_selected} is not available for trip {data.id}" ) text = ( f"Điểm đón không có sẵn cho chuyến xe. " "Vui lòng chọn điểm đón khác." ) params = {"is_valid_pickup": False} return DialogServiceResult(text=[text], parameters=params) if role == "drop-off": if not id_selected: logger.error("Invalid drop-off selection data") return DialogServiceResult(parameters={"is_valid_dropoff": False}) drop_offs = await self.trip_repository.get_stop( data.route_id, data.way_id, role="drop-off" ) matched_drop_off = next( (s for s in drop_offs if s.get("id") == id_selected), None ) # Kiểm tra xem điểm đón có trong danh sách điểm dừng của chuyến xe không if matched_drop_off: drop_off_name = matched_drop_off.get("name") text = f"Bạn đã chọn điểm trả **{drop_off_name}** cho chuyến xe." params = {"dropoff": drop_off_name, "is_valid_dropoff": True} return DialogServiceResult( text=[text], parameters=params, ) logger.info( f"Drop-off point {id_selected} is not available for trip {data.id}" ) text = ( f"Điểm trả không có sẵn cho chuyến xe. " "Vui lòng chọn điểm trả khác." ) params = {"is_valid_dropoff": False} return DialogServiceResult(text=[text], parameters=params) else: logger.error(f"Invalid role for stop selection: {role}") raise HTTPException( status_code=400, detail="Invalid role for stop selection" ) async def get_drop_off_points(self, route_id: int, way_id: int) -> list: """ Lấy danh sách các điểm dừng trả khách của chuyến xe theo route_id và way_id. Args: route_id (int): ID của tuyến xe. way_id (int): ID của lộ trình. Returns: list[dict]: Danh sách các điểm dừng trả khách. """ drop_offs = await self.trip_repository.get_stop( route_id, way_id, role="drop-off" ) if not drop_offs: logger.error( f"No drop-off stops found for route {route_id} and way {way_id}" ) text = "Quý khách vui lòng chọn điểm trả cho chuyến xe của mình" text += "\n" + " | ".join(f"{s['id']}" for i, s in enumerate(drop_offs)) return DialogServiceResult(text=[text], options=drop_offs) @staticmethod async def make_ticket_summary(ticket: TicketRequest) -> DialogServiceResult: """ Tạo tóm tắt vé xe dựa trên thông tin người dùng và chuyến xe. Args: ticket (TicketRequest): Thông tin vé xe bao gồm người dùng, chuyến xe, ghế, điểm đón/trả. Returns: DialogServiceResult: Kết quả tóm tắt vé xe. """ if not ticket or not ticket.trip_data: logger.error("Invalid ticket data for summary") return DialogServiceResult(text=["Thông tin vé không hợp lệ."]) text = f" \ **Thông tin hành khách**\n\ **Họ và tên** {ticket.user_name} \n\ **Số điện thoại** {ticket.phone_number}\n\ **Email** {ticket.email} \n\ **Thông tin lượt đi**\n\ **Tuyến xe** {ticket.trip_data.route.name} \n\ **Thời gian xuất bến** {ticket.trip_data.raw_departure_time} {ticket.trip_data.raw_departure_date} \n\ **Số ghế** {ticket.seat.upper()} \n\ **Điểm lên xe** {ticket.pickup} \n\ **Điểm trả khách** {ticket.dropoff} \n\ **Tổng tiền lượt đi** {ticket.trip_data.price} VND \ " options = [ "Đặt vé", "Không, cảm ơn", ] return DialogServiceResult(text=[text], options=options)