Spaces:
Sleeping
Sleeping
| 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 | |
| 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 {} | |
| 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) | |
| 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 | |
| ) | |
| 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) | |
| 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) | |