diff --git a/.gitignore b/.gitignore index 415e9f5..18c4e27 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ DOCS.md .env* .flaskenv* !.env.project -!.env.vault \ No newline at end of file +!.env.vault + diff --git a/package-lock.json b/package-lock.json index 268eb43..7992ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5352,6 +5352,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", diff --git a/src/app.js b/src/app.js index 4a7d9f6..442c7a0 100644 --- a/src/app.js +++ b/src/app.js @@ -16,7 +16,7 @@ const dateChangeRoutes = require('./routes/date-change.routes'); const seatRoutes = require('./routes/seat.routes'); const checkinRoutes = require('./routes/checkin.routes'); -const { expireHeldBookings } = require("./services/booking.service"); +const { expireHeldBookings, autoCompleteFlights } = require("./services/booking.service"); const { autoGenerateFlights } = require("./services/admin/flight.service"); const { checkAndAlertSLABreach } = require("./services/notification.service"); const { runBatch: autoFlightBatch } = require("./services/admin/auto-flight.service"); diff --git a/src/config/refund.config.js b/src/config/refund.config.js index 09e5408..4fe2371 100644 --- a/src/config/refund.config.js +++ b/src/config/refund.config.js @@ -159,6 +159,13 @@ const NOTIFICATIONS = { // Airline cancellation sendFlightCancelledNotifications: true, + + // Flight notifications + sendFlightStatusChangeNotifications: true, + sendFlightDelayNotifications: true, + sendFlightGateChangeNotifications: true, + sendFlightTimeChangeNotifications: true, + sendFlightBaggageChangeNotifications: true, }, // Admin dashboard notification diff --git a/src/controllers/admin/price-rule.controller.js b/src/controllers/admin/price-rule.controller.js new file mode 100644 index 0000000..a800953 --- /dev/null +++ b/src/controllers/admin/price-rule.controller.js @@ -0,0 +1,74 @@ +"use strict"; + +const priceRuleService = require("../../services/price-rule.service"); + +/** GET /api/admin/price-rules */ +const getAllRules = async (req, res) => { + try { + const data = await priceRuleService.getAllRules(); + res.json({ message: "Lấy danh sách price rules thành công", data }); + } catch (err) { res.status(400).json({ error: err.message }); } +}; + +/** GET /api/admin/price-rules/:id */ +const getRuleById = async (req, res) => { + try { + const data = await priceRuleService.getRuleById(req.params.id); + res.json({ data }); + } catch (err) { res.status(404).json({ error: err.message }); } +}; + +/** POST /api/admin/price-rules */ +const createRule = async (req, res) => { + try { + const data = await priceRuleService.createRule(req.body); + res.status(201).json({ message: "Tạo price rule thành công", data }); + } catch (err) { res.status(400).json({ error: err.message }); } +}; + +/** PUT /api/admin/price-rules/:id */ +const updateRule = async (req, res) => { + try { + const data = await priceRuleService.updateRule(req.params.id, req.body); + res.json({ message: "Cập nhật price rule thành công", data }); + } catch (err) { res.status(400).json({ error: err.message }); } +}; + +/** DELETE /api/admin/price-rules/:id */ +const deleteRule = async (req, res) => { + try { + const data = await priceRuleService.deleteRule(req.params.id); + res.json(data); + } catch (err) { res.status(400).json({ error: err.message }); } +}; + +/** PATCH /api/admin/price-rules/:id/toggle */ +const toggleRule = async (req, res) => { + try { + const data = await priceRuleService.toggleRule(req.params.id); + res.json(data); + } catch (err) { res.status(400).json({ error: err.message }); } +}; + +/** + * GET /api/admin/price-rules/preview + * Xem giá sẽ thay đổi thế nào khi áp dụng rule + * Query: ?base_price=1000000&date=2026-01-30&seat_class=economy + */ +const previewPrice = async (req, res) => { + try { + const { base_price, date, seat_class, airline_code, departure_code, arrival_code } = req.query; + if (!base_price || !date) { + return res.status(400).json({ error: "base_price và date là bắt buộc" }); + } + const data = await priceRuleService.previewPrice(base_price, date, { + seatClass: seat_class, + airlineCode: airline_code, + departureCode: departure_code, + arrivalCode: arrival_code, + }); + res.json({ data }); + } catch (err) { res.status(400).json({ error: err.message }); } +}; + +module.exports = { getAllRules, getRuleById, createRule, updateRule, deleteRule, toggleRule, previewPrice }; \ No newline at end of file diff --git a/src/controllers/flight-brand.controller.js b/src/controllers/flight-brand.controller.js new file mode 100644 index 0000000..642da30 --- /dev/null +++ b/src/controllers/flight-brand.controller.js @@ -0,0 +1,42 @@ +"use strict"; + +const flightBrandService = require("../services/flight-brand.service"); + +/** + * GET /api/flights/brand-combinations + * Gợi ý kết hợp hãng tối ưu cho khứ hồi + * + * Query params: + * departure_code, arrival_code (bắt buộc) + * departure_date, return_date (bắt buộc - YYYY-MM-DD) + * seat_class (mặc định: economy) + * adults, children, infants (mặc định: 1,0,0) + * limit (mặc định: 5) + */ +const getBrandCombinations = async (req, res) => { + try { + const { + departure_code, arrival_code, + departure_date, return_date, + seat_class = 'economy', + adults = 1, children = 0, infants = 0, + limit = 5, + } = req.query; + + const result = await flightBrandService.getBrandCombinations({ + departure_code, arrival_code, + departure_date, return_date, + seat_class, adults, children, infants, + limit, + }); + + res.json({ + message: "Gợi ý kết hợp hãng bay thành công", + data: result, + }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}; + +module.exports = { getBrandCombinations }; \ No newline at end of file diff --git a/src/queries/price-rule.queries.js b/src/queries/price-rule.queries.js new file mode 100644 index 0000000..7cc04e6 --- /dev/null +++ b/src/queries/price-rule.queries.js @@ -0,0 +1,78 @@ +"use strict"; + +// Lấy tất cả rules đang active cho 1 ngày cụ thể +// Dùng khi search flight để tính adjusted_price +const GET_ACTIVE_RULES_FOR_DATE = ` + SELECT + id, name, type, + adjustment_type, adjustment_value, + applies_to_class, + applies_to_airline_code, + applies_to_departure_code, + applies_to_arrival_code, + priority + FROM price_rules + WHERE is_active = TRUE + AND start_date <= $1::DATE + AND end_date >= $1::DATE + ORDER BY priority DESC +`; + +// CRUD cho admin +const GET_ALL_RULES = ` + SELECT * FROM price_rules + ORDER BY start_date DESC, priority DESC +`; + +const GET_RULE_BY_ID = ` + SELECT * FROM price_rules WHERE id = $1 +`; + +const INSERT_RULE = ` + INSERT INTO price_rules ( + name, type, start_date, end_date, + adjustment_type, adjustment_value, + applies_to_class, applies_to_airline_code, + applies_to_departure_code, applies_to_arrival_code, + priority, is_active, created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'admin') + RETURNING * +`; + +const UPDATE_RULE = ` + UPDATE price_rules + SET name = COALESCE($2, name), + type = COALESCE($3, type), + start_date = COALESCE($4, start_date), + end_date = COALESCE($5, end_date), + adjustment_type = COALESCE($6, adjustment_type), + adjustment_value = COALESCE($7, adjustment_value), + applies_to_class = $8, + applies_to_airline_code = $9, + applies_to_departure_code= $10, + applies_to_arrival_code = $11, + priority = COALESCE($12, priority), + is_active = COALESCE($13, is_active), + updated_at = NOW() + WHERE id = $1 + RETURNING * +`; + +const DELETE_RULE = ` + DELETE FROM price_rules WHERE id = $1 AND created_by = 'admin' RETURNING id +`; + +const TOGGLE_RULE = ` + UPDATE price_rules SET is_active = NOT is_active, updated_at = NOW() + WHERE id = $1 RETURNING id, name, is_active +`; + +module.exports = { + GET_ACTIVE_RULES_FOR_DATE, + GET_ALL_RULES, + GET_RULE_BY_ID, + INSERT_RULE, + UPDATE_RULE, + DELETE_RULE, + TOGGLE_RULE, +}; \ No newline at end of file diff --git a/src/services/admin/flight.service.js b/src/services/admin/flight.service.js index 82ff789..62e93c2 100644 --- a/src/services/admin/flight.service.js +++ b/src/services/admin/flight.service.js @@ -7,7 +7,15 @@ const QS = require("../../queries/schedule.queries"); // ─── Helpers ────────────────────────────────────────────────────────────────── -const VALID_STATUSES = ["scheduled", "delayed", "boarding", "departed", "arrived", "cancelled", "completed"]; +const VALID_STATUSES = [ + "scheduled", + "delayed", + "boarding", + "departed", + "arrived", + "cancelled", + "completed", +]; const VALID_CLASSES = ["economy", "business", "first"]; const validateFlightInput = (data, isUpdate = false) => { @@ -440,10 +448,10 @@ const updateFlightStatus = async (flightId, status, reason = "") => { // 3. Payload thông báo const statusLabels = { scheduled: "Đúng giờ", - delayed: "Bị trễ", - boarding: "Đang lên máy bay", - departed: "Đã khởi hành", - arrived: "Đã hạ cánh", + delayed: "Bị trễ", + boarding: "Đang lên máy bay", + departed: "Đã khởi hành", + arrived: "Đã hạ cánh", cancelled: "Đã hủy", completed: "Đã hoàn thành", }; @@ -496,10 +504,10 @@ const updateFlightStatus = async (flightId, status, reason = "") => { const statusLabels = { scheduled: "Đúng giờ", - delayed: "Bị trễ", - boarding: "Đang lên máy bay", - departed: "Đã khởi hành", - arrived: "Đã hạ cánh", + delayed: "Bị trễ", + boarding: "Đang lên máy bay", + departed: "Đã khởi hành", + arrived: "Đã hạ cánh", cancelled: "Đã hủy", completed: "Đã hoàn thành", }; @@ -513,18 +521,18 @@ const updateFlightStatus = async (flightId, status, reason = "") => { // Fire-and-forget — không block response sendFlightStatusEmail(booking.contact_email, { - contactName: booking.contact_name, - bookingCode: booking.booking_code, - flightNumber: flight.flight_number, - airlineName: fd.airline_name || "", - depCode: fd.departure_code || "", - depCity: fd.departure_city || "", - arrCode: fd.arrival_code || "", - arrCity: fd.arrival_city || "", + contactName: booking.contact_name, + bookingCode: booking.booking_code, + flightNumber: flight.flight_number, + airlineName: fd.airline_name || "", + depCode: fd.departure_code || "", + depCity: fd.departure_city || "", + arrCode: fd.arrival_code || "", + arrCity: fd.arrival_city || "", departureTime: fd.departure_time || flight.departure_time, - newStatus: status, - statusLabel: statusLabels[status] || status, - reason: reason || "", + newStatus: status, + statusLabel: statusLabels[status] || status, + reason: reason || "", }).catch((e) => console.error("[AD-05 Email]", e.message)); } } catch (emailErr) { @@ -767,7 +775,15 @@ const toggleAirlineStatus = async (airlineId) => { // ══════════════════════════════════════════════════════ const getBookings = async (params) => { - const { page = 1, limit = 10, status, trip_type, search, from_date, to_date } = params; + const { + page = 1, + limit = 10, + status, + trip_type, + search, + from_date, + to_date, + } = params; const offset = (parseInt(page) - 1) * parseInt(limit); const conditions = []; const values = []; @@ -789,7 +805,9 @@ const getBookings = async (params) => { values.push(`%${search}%`); } if (from_date && to_date) { - conditions.push(`(b.created_at AT TIME ZONE '+07')::date BETWEEN $${idx} AND $${idx + 1}`); + conditions.push( + `(b.created_at AT TIME ZONE '+07')::date BETWEEN $${idx} AND $${idx + 1}`, + ); idx += 2; values.push(from_date, to_date); } @@ -855,7 +873,7 @@ const getStatistics = async (params) => { if (from_date && to_date) { dateValues.push(from_date, to_date); - dateFilter = `AND (created_at AT TIME ZONE '+07')::date BETWEEN $1::date AND $2::date`; + dateFilter = `AND (created_at AT TIME ZONE '+07')::date BETWEEN $1::date AND $2::date`; bDateFilter = `AND (b.created_at AT TIME ZONE '+07')::date BETWEEN $1::date AND $2::date`; } diff --git a/src/services/booking.service.js b/src/services/booking.service.js index 48caed7..c4d384b 100644 --- a/src/services/booking.service.js +++ b/src/services/booking.service.js @@ -506,7 +506,136 @@ const cancelBooking = async (userId, bookingCode, reason = null) => { }; const expireHeldBookings = async () => { - // ... (giữ nguyên) + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Tìm booking pending đã hết thời gian giữ ghế + const expired = await client.query( + `SELECT * FROM bookings + WHERE status = 'pending' + AND held_until < NOW() + FOR UPDATE SKIP LOCKED` + ); + + for (const booking of expired.rows) { + const seatsNeeded = booking.total_adults + booking.total_children; + + // Hoàn ghế chuyến đi + await client.query( + `UPDATE flight_seats + SET available_seats = available_seats + $1, updated_at = NOW() + WHERE flight_id = $2 AND class = $3`, + [seatsNeeded, booking.outbound_flight_id, booking.outbound_seat_class] + ); + + // Hoàn ghế chuyến về (nếu khứ hồi) + if (booking.return_flight_id) { + await client.query( + `UPDATE flight_seats + SET available_seats = available_seats + $1, updated_at = NOW() + WHERE flight_id = $2 AND class = $3`, + [seatsNeeded, booking.return_flight_id, booking.return_seat_class] + ); + } + + // Đánh dấu booking là expired + await client.query( + `UPDATE bookings SET status = 'expired', updated_at = NOW() WHERE id = $1`, + [booking.id] + ); + } + + await client.query("COMMIT"); + + if (expired.rows.length > 0) { + console.log(`[Auto-expire] Đã hủy ${expired.rows.length} booking hết hạn`); + } + } catch (err) { + await client.query("ROLLBACK"); + console.error("[Auto-expire] Lỗi:", err.message); + } finally { + client.release(); + } +}; + + +/** + * CẤP 2 — Auto-complete chuyến bay đã bay + hủy booking của chuyến cancelled + * Chạy mỗi phút qua cron trong app.js + */ +const autoCompleteFlights = async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. Chuyển tất cả chuyến bay đã khởi hành sang "completed" + const completedResult = await client.query(` + UPDATE flights + SET status = 'completed', + updated_at = NOW() + WHERE departure_time < NOW() + AND (departure_time + (duration_minutes * 0.3 * INTERVAL '1 minute')) < NOW() + AND status NOT IN ('cancelled', 'completed') + RETURNING id, flight_number + `); + // chỉ check chuyến bay //////// WARNING: + if (completedResult.rows.length > 0) { + console.log( + `[AutoComplete] Đã hoàn thành ${completedResult.rows.length} chuyến bay:`, + completedResult.rows.map(r => r.flight_number).join(", ") + ); + + // 2. Tự động expire các booking "pending" của chuyến đã completed + // (user giữ ghế nhưng chưa thanh toán, chuyến đã bay → hủy luôn) + for (const flight of completedResult.rows) { + const expiredBookings = await client.query(` + SELECT id, total_adults, total_children, + outbound_flight_id, outbound_seat_class, + return_flight_id, return_seat_class + FROM bookings + WHERE (outbound_flight_id = $1 OR return_flight_id = $1) + AND status = 'pending' + `, [flight.id]); + + for (const booking of expiredBookings.rows) { + const seats = booking.total_adults + booking.total_children; + + // Hoàn ghế chuyến đi + await client.query(` + UPDATE flight_seats + SET available_seats = available_seats + $1, updated_at = NOW() + WHERE flight_id = $2 AND class = $3 + `, [seats, booking.outbound_flight_id, booking.outbound_seat_class]); + + // Hoàn ghế chuyến về (nếu có) + if (booking.return_flight_id) { + await client.query(` + UPDATE flight_seats + SET available_seats = available_seats + $1, updated_at = NOW() + WHERE flight_id = $2 AND class = $3 + `, [seats, booking.return_flight_id, booking.return_seat_class]); + } + + // Đánh dấu booking là expired + await client.query(` + UPDATE bookings SET status = 'expired', updated_at = NOW() WHERE id = $1 + `, [booking.id]); + } + + if (expiredBookings.rows.length > 0) { + console.log(`[AutoComplete] Đã expire ${expiredBookings.rows.length} booking pending của chuyến ${flight.flight_number}`); + } + } + } + + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + console.error("[AutoComplete] Lỗi:", err.message); + } finally { + client.release(); + } }; -module.exports = { createBooking, getBookingDetail, getMyBookings, cancelBooking, expireHeldBookings }; \ No newline at end of file +module.exports = { createBooking, getBookingDetail, getMyBookings, cancelBooking, expireHeldBookings, autoCompleteFlights }; \ No newline at end of file diff --git a/src/services/flight-brand.service.js b/src/services/flight-brand.service.js new file mode 100644 index 0000000..157a223 --- /dev/null +++ b/src/services/flight-brand.service.js @@ -0,0 +1,229 @@ +"use strict"; + +const pool = require("../config/db"); + +/** + * FLIGHT BRANDS — Gợi ý kết hợp hãng tối ưu cho khứ hồi + * + * Logic: + * 1. Tìm tất cả chuyến đi còn vé (dep → arr, departure_date) + * 2. Tìm tất cả chuyến về còn vé (arr → dep, return_date) + * 3. Ghép các cặp chuyến đi/về khác hãng + * 4. Chấm điểm theo: tổng giá, thời gian chờ, hãng uy tín + * 5. Trả về top combinations + */ + +// Điểm uy tín hãng (dựa theo thực tế) +const AIRLINE_SCORE = { + SQ: 95, QR: 94, EK: 93, CX: 92, NH: 91, JL: 90, + TG: 88, MH: 87, KE: 86, OZ: 85, GA: 84, + VN: 82, LH: 82, BA: 81, AF: 80, KL: 80, + QF: 79, EY: 78, TK: 77, CA: 76, MU: 75, + VJ: 70, AK: 68, QH: 72, TR: 68, JQ: 65, +}; + +const getAirlineScore = (code) => AIRLINE_SCORE[code] || 60; + +/** + * Query chuyến bay theo tuyến + ngày + */ +const queryFlightsByRoute = async (depCode, arrCode, date, seatClass, passengers) => { + const seatsNeeded = passengers.adults + passengers.children; + + const result = await pool.query(` + SELECT + f.id, f.flight_number, f.departure_time, f.arrival_time, f.duration_minutes, + al.id AS airline_id, al.code AS airline_code, al.name AS airline_name, + al.logo_url, al.logo_dark, al.logo_light, + dep.code AS dep_code, dep.city AS dep_city, dep.name AS dep_name, + arr.code AS arr_code, arr.city AS arr_city, arr.name AS arr_name, + fs.base_price, fs.available_seats, fs.baggage_included_kg, + fs.carry_on_kg, fs.extra_baggage_price + FROM flights f + JOIN airlines al ON al.id = f.airline_id + JOIN airports dep ON dep.id = f.departure_airport_id + JOIN airports arr ON arr.id = f.arrival_airport_id + JOIN flight_seats fs ON fs.flight_id = f.id AND fs.class = $3 + WHERE dep.code = $1 + AND arr.code = $2 + AND DATE(f.departure_time) = $4 + AND f.status = 'scheduled' + AND f.is_active = TRUE + AND fs.available_seats >= $5 + ORDER BY fs.base_price ASC + LIMIT 20 + `, [depCode, arrCode, seatClass, date, seatsNeeded]); + + return result.rows; +}; + +/** + * Tính tổng tiền theo hành khách + */ +const calcTotal = (basePrice, adults, children, infants) => + Math.round(basePrice * adults + basePrice * 0.75 * children + basePrice * 0.10 * infants); + +/** + * Format 1 chuyến bay + */ +const formatFlight = (r, passengers) => { + const base = parseFloat(r.base_price); + const total = calcTotal(base, passengers.adults, passengers.children, passengers.infants); + return { + flight_id: r.id, + flight_number: r.flight_number, + departure: { code: r.dep_code, city: r.dep_city, name: r.dep_name, time: r.departure_time }, + arrival: { code: r.arr_code, city: r.arr_city, name: r.arr_name, time: r.arrival_time }, + duration_minutes: r.duration_minutes, + duration_label: formatDuration(r.duration_minutes), + airline: { + id: r.airline_id, + code: r.airline_code, + name: r.airline_name, + logo_url: r.logo_url, + logo_dark: r.logo_dark, + logo_light: r.logo_light, + score: getAirlineScore(r.airline_code), + }, + seat: { + base_price: base, + total_price: total, + available_seats: r.available_seats, + baggage_included_kg: r.baggage_included_kg, + carry_on_kg: r.carry_on_kg, + extra_baggage_price: parseFloat(r.extra_baggage_price) || 0, + }, + }; +}; + +const formatDuration = (min) => { + const h = Math.floor(min / 60), m = min % 60; + return m > 0 ? `${h}h${m}m` : `${h}h`; +}; + +/** + * Gợi ý kết hợp hãng tối ưu + * GET /api/flights/brand-combinations + */ +const getBrandCombinations = async ({ + departure_code, arrival_code, + departure_date, return_date, + seat_class = 'economy', + adults = 1, children = 0, infants = 0, + limit = 5, +}) => { + if (!departure_code || !arrival_code || !departure_date || !return_date) { + throw new Error("departure_code, arrival_code, departure_date, return_date là bắt buộc"); + } + + const dep = departure_code.toUpperCase(); + const arr = arrival_code.toUpperCase(); + const cls = seat_class.toLowerCase(); + const pax = { adults: parseInt(adults), children: parseInt(children), infants: parseInt(infants) }; + const lim = parseInt(limit); + + // 1. Lấy chuyến đi + chuyến về song song + const [outboundRows, returnRows] = await Promise.all([ + queryFlightsByRoute(dep, arr, departure_date, cls, pax), + queryFlightsByRoute(arr, dep, return_date, cls, pax), + ]); + + if (outboundRows.length === 0) throw new Error("Không tìm thấy chuyến đi phù hợp"); + if (returnRows.length === 0) throw new Error("Không tìm thấy chuyến về phù hợp"); + + // 2. Format flights + const outboundFlights = outboundRows.map(r => formatFlight(r, pax)); + const returnFlights = returnRows.map(r => formatFlight(r, pax)); + + // 3. Ghép tất cả cặp (outbound × return) và chấm điểm + const combinations = []; + + for (const out of outboundFlights) { + for (const ret of returnFlights) { + const isSameAirline = out.airline.code === ret.airline.code; + const totalPrice = out.seat.total_price + ret.seat.total_price; + const avgAirlineScore = (out.airline.score + ret.airline.score) / 2; + + // Tính thời gian chờ ở điểm đến (arr → dep return) + const arrivalTime = new Date(out.arrival.time); + const returnDepTime = new Date(ret.departure.time); + const layoverHours = (returnDepTime - arrivalTime) / (1000 * 60 * 60); + + // Bỏ qua nếu thời gian chờ quá ngắn (< 2h) hoặc quá dài (> 30 ngày) + if (layoverHours < 2 || layoverHours > 24 * 30) continue; + + // Scoring combination + let score = 0; + + // Tổng giá thấp: +40 điểm (normalize) + const minPossiblePrice = outboundFlights[0].seat.total_price + returnFlights[0].seat.total_price; + const priceScore = Math.max(0, 40 - Math.round(((totalPrice - minPossiblePrice) / minPossiblePrice) * 40)); + score += priceScore; + + // Hãng khác nhau (đây là tính năng chính): +20 nếu tiết kiệm hơn same-airline + if (!isSameAirline) score += 20; + + // Điểm uy tín hãng trung bình + score += Math.round(avgAirlineScore / 5); + + // Thời gian chờ hợp lý (3-12h): +10 + if (layoverHours >= 3 && layoverHours <= 12) score += 10; + + // Bay thẳng cả 2 chiều: +10 + if (out.duration_minutes < 300 && ret.duration_minutes < 300) score += 10; + + combinations.push({ + outbound_flight: out, + return_flight: ret, + is_same_airline: isSameAirline, + airlines: isSameAirline + ? `${out.airline.name} (cả 2 chiều)` + : `${out.airline.name} + ${ret.airline.name}`, + total_price: totalPrice, + layover_hours: Math.round(layoverHours * 10) / 10, + score, + highlight: !isSameAirline && priceScore >= 20 + ? `Tiết kiệm hơn khi kết hợp ${out.airline.name} & ${ret.airline.name}` + : null, + }); + } + } + + if (combinations.length === 0) { + throw new Error("Không tìm thấy kết hợp hãng bay phù hợp"); + } + + // 4. Sắp xếp theo score DESC, lấy top N + combinations.sort((a, b) => b.score - a.score || a.total_price - b.total_price); + const topCombinations = combinations.slice(0, lim); + + // 5. Tách nhóm: cùng hãng vs khác hãng + const sameAirline = topCombinations.filter(c => c.is_same_airline); + const mixedAirline = topCombinations.filter(c => !c.is_same_airline); + + // Giá thấp nhất trong top + const cheapest = topCombinations[0]; + const cheapestSame = sameAirline[0] || null; + const cheapestMixed = mixedAirline[0] || null; + + return { + passengers: { adults: pax.adults, children: pax.children, infants: pax.infants }, + seat_class: cls, + route: { departure_code: dep, arrival_code: arr, departure_date, return_date }, + summary: { + total_combinations: topCombinations.length, + same_airline_count: sameAirline.length, + mixed_airline_count: mixedAirline.length, + cheapest_total: cheapest.total_price, + // So sánh giá cùng hãng vs khác hãng + same_airline_price: cheapestSame ? cheapestSame.total_price : null, + mixed_airline_price: cheapestMixed ? cheapestMixed.total_price : null, + saving_by_mixing: cheapestSame && cheapestMixed + ? cheapestSame.total_price - cheapestMixed.total_price + : null, + }, + combinations: topCombinations, + }; +}; + +module.exports = { getBrandCombinations }; \ No newline at end of file diff --git a/src/services/notification.service.js b/src/services/notification.service.js index 8326132..d80840c 100644 --- a/src/services/notification.service.js +++ b/src/services/notification.service.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /* ========================================================= @@ -12,11 +12,11 @@ Gửi email khi có sự kiện liên quan đến: ========================================================= */ -const { NOTIFICATIONS } = require('../config/refund.config'); +const { NOTIFICATIONS } = require("../config/refund.config"); // Email sender address -const FROM_EMAIL = NOTIFICATIONS.email.from || 'no-reply@n4minhlong.io.vn'; -const FROM_NAME = NOTIFICATIONS.email.fromName || 'Airline Booking System'; +const FROM_EMAIL = NOTIFICATIONS.email.from || "no-reply@n4minhlong.io.vn"; +const FROM_NAME = NOTIFICATIONS.email.fromName || "Airline Booking System"; // ========================================================= // EMAIL TEMPLATES @@ -25,48 +25,70 @@ const FROM_NAME = NOTIFICATIONS.email.fromName || 'Airline Booking System'; const EMAIL_TEMPLATES = { // Refund Templates REFUND_REQUESTED: { - subject: 'Xác nhận yêu cầu hoàn tiền - {refund_code}', - template: 'refund-requested', + subject: "Xác nhận yêu cầu hoàn tiền - {refund_code}", + template: "refund-requested", }, REFUND_APPROVED: { - subject: 'Yêu cầu hoàn tiền đã được duyệt - {refund_code}', - template: 'refund-approved', + subject: "Yêu cầu hoàn tiền đã được duyệt - {refund_code}", + template: "refund-approved", }, REFUND_REJECTED: { - subject: 'Yêu cầu hoàn tiền bị từ chối - {refund_code}', - template: 'refund-rejected', + subject: "Yêu cầu hoàn tiền bị từ chối - {refund_code}", + template: "refund-rejected", }, REFUND_COMPLETED: { - subject: 'Hoàn tiền thành công - {refund_code}', - template: 'refund-completed', + subject: "Hoàn tiền thành công - {refund_code}", + template: "refund-completed", }, REFUND_FAILED: { - subject: 'Hoàn tiền thất bại - {refund_code}', - template: 'refund-failed', + subject: "Hoàn tiền thất bại - {refund_code}", + template: "refund-failed", }, // Date Change Templates DATE_CHANGE_REQUESTED: { - subject: 'Xác nhận yêu cầu đổi ngày bay - {request_code}', - template: 'date-change-requested', + subject: "Xác nhận yêu cầu đổi ngày bay - {request_code}", + template: "date-change-requested", }, DATE_CHANGE_APPROVED: { - subject: 'Yêu cầu đổi ngày bay đã được duyệt - {request_code}', - template: 'date-change-approved', + subject: "Yêu cầu đổi ngày bay đã được duyệt - {request_code}", + template: "date-change-approved", }, DATE_CHANGE_REJECTED: { - subject: 'Yêu cầu đổi ngày bay bị từ chối - {request_code}', - template: 'date-change-rejected', + subject: "Yêu cầu đổi ngày bay bị từ chối - {request_code}", + template: "date-change-rejected", }, // Flight Cancellation Templates FLIGHT_CANCELLED: { - subject: 'Thông báo: Chuyến bay {flight_number} đã bị hủy', - template: 'flight-cancelled', + subject: "Thông báo: Chuyến bay {flight_number} đã bị hủy", + template: "flight-cancelled", }, BOOKING_AFFECTED_BY_CANCELLATION: { - subject: 'Thông tin hoàn tiền cho booking {booking_code}', - template: 'booking-refund-notice', + subject: "Thông tin hoàn tiền cho booking {booking_code}", + template: "booking-refund-notice", + }, + + // Flight Notifications + FLIGHT_STATUS_CHANGED: { + subject: "Cập nhật trạng thái chuyến bay {flight_number}", + template: "flight-status-change", + }, + FLIGHT_DELAYED: { + subject: "Chuyến bay {flight_number} bị trễ", + template: "flight-delayed", + }, + FLIGHT_GATE_CHANGED: { + subject: "Đổi cổng lên máy bay - Chuyến bay {flight_number}", + template: "flight-gate-change", + }, + FLIGHT_TIME_CHANGED: { + subject: "Thay đổi giờ bay - Chuyến bay {flight_number}", + template: "flight-time-change", + }, + FLIGHT_BAGGAGE_CHANGED: { + subject: "Cập nhật hành lý - Chuyến bay {flight_number}", + template: "flight-baggage-change", }, }; @@ -80,18 +102,23 @@ const generateRefundEmailContent = (event, data) => { const baseData = { refund_code: refund.refund_code, booking_code: booking?.booking_code || refund.booking_code, - refund_amount: formatCurrency(refund.net_refund_amount || refund.refund_amount), + refund_amount: formatCurrency( + refund.net_refund_amount || refund.refund_amount, + ), refund_type: refund.refund_type, status: refund.status, - admin_notes: refund.admin_notes || '', + admin_notes: refund.admin_notes || "", created_at: formatDate(refund.created_at), completed_at: refund.completed_at ? formatDate(refund.completed_at) : null, }; switch (event) { - case 'REFUND_REQUESTED': + case "REFUND_REQUESTED": return { - subject: EMAIL_TEMPLATES.REFUND_REQUESTED.subject.replace('{refund_code}', refund.refund_code), + subject: EMAIL_TEMPLATES.REFUND_REQUESTED.subject.replace( + "{refund_code}", + refund.refund_code, + ), body: ` Xin chào, @@ -103,7 +130,7 @@ const generateRefundEmailContent = (event, data) => { - Loại refund: ${refund.refund_type} - Số tiền hoàn: ${formatCurrency(refund.net_refund_amount || refund.refund_amount)} - Ngày yêu cầu: ${formatDate(refund.created_at)} - - Lý do: ${refund.reason || 'Không có'} + - Lý do: ${refund.reason || "Không có"} Chúng tôi sẽ xử lý yêu cầu trong vòng 7 ngày làm việc. @@ -112,9 +139,12 @@ const generateRefundEmailContent = (event, data) => { `.trim(), }; - case 'REFUND_APPROVED': + case "REFUND_APPROVED": return { - subject: EMAIL_TEMPLATES.REFUND_APPROVED.subject.replace('{refund_code}', refund.refund_code), + subject: EMAIL_TEMPLATES.REFUND_APPROVED.subject.replace( + "{refund_code}", + refund.refund_code, + ), body: ` Xin chào, @@ -124,7 +154,7 @@ const generateRefundEmailContent = (event, data) => { - Mã yêu cầu: ${refund.refund_code} - Mã booking: ${booking?.booking_code || refund.booking_code} - Số tiền hoàn: ${formatCurrency(refund.net_refund_amount || refund.refund_amount)} - - Ghi chú: ${refund.admin_notes || 'Không có'} + - Ghi chú: ${refund.admin_notes || "Không có"} Tiền sẽ được hoàn trong vòng 3-5 ngày làm việc. @@ -133,9 +163,12 @@ const generateRefundEmailContent = (event, data) => { `.trim(), }; - case 'REFUND_REJECTED': + case "REFUND_REJECTED": return { - subject: EMAIL_TEMPLATES.REFUND_REJECTED.subject.replace('{refund_code}', refund.refund_code), + subject: EMAIL_TEMPLATES.REFUND_REJECTED.subject.replace( + "{refund_code}", + refund.refund_code, + ), body: ` Xin chào, @@ -144,7 +177,7 @@ const generateRefundEmailContent = (event, data) => { Thông tin: - Mã yêu cầu: ${refund.refund_code} - Mã booking: ${booking?.booking_code || refund.booking_code} - - Lý do từ chối: ${refund.admin_notes || refund.reason || 'Không có'} + - Lý do từ chối: ${refund.admin_notes || refund.reason || "Không có"} Nếu bạn cần hỗ trợ thêm, vui lòng liên hệ với chúng tôi. @@ -153,9 +186,12 @@ const generateRefundEmailContent = (event, data) => { `.trim(), }; - case 'REFUND_COMPLETED': + case "REFUND_COMPLETED": return { - subject: EMAIL_TEMPLATES.REFUND_COMPLETED.subject.replace('{refund_code}', refund.refund_code), + subject: EMAIL_TEMPLATES.REFUND_COMPLETED.subject.replace( + "{refund_code}", + refund.refund_code, + ), body: ` Xin chào, @@ -188,9 +224,12 @@ const generateDateChangeEmailContent = (event, data) => { const { request, booking } = data; switch (event) { - case 'DATE_CHANGE_REQUESTED': + case "DATE_CHANGE_REQUESTED": return { - subject: EMAIL_TEMPLATES.DATE_CHANGE_REQUESTED.subject.replace('{request_code}', request.request_code), + subject: EMAIL_TEMPLATES.DATE_CHANGE_REQUESTED.subject.replace( + "{request_code}", + request.request_code, + ), body: ` Xin chào, @@ -208,9 +247,12 @@ const generateDateChangeEmailContent = (event, data) => { `.trim(), }; - case 'DATE_CHANGE_APPROVED': + case "DATE_CHANGE_APPROVED": return { - subject: EMAIL_TEMPLATES.DATE_CHANGE_APPROVED.subject.replace('{request_code}', request.request_code), + subject: EMAIL_TEMPLATES.DATE_CHANGE_APPROVED.subject.replace( + "{request_code}", + request.request_code, + ), body: ` Xin chào, @@ -219,16 +261,19 @@ const generateDateChangeEmailContent = (event, data) => { Thông tin: - Mã yêu cầu: ${request.request_code} - Mã booking: ${booking?.booking_code || request.booking_code} - - Ghi chú: ${request.admin_notes || 'Không có'} + - Ghi chú: ${request.admin_notes || "Không có"} Trân trọng, Đội ngũ hỗ trợ `.trim(), }; - case 'DATE_CHANGE_REJECTED': + case "DATE_CHANGE_REJECTED": return { - subject: EMAIL_TEMPLATES.DATE_CHANGE_REJECTED.subject.replace('{request_code}', request.request_code), + subject: EMAIL_TEMPLATES.DATE_CHANGE_REJECTED.subject.replace( + "{request_code}", + request.request_code, + ), body: ` Xin chào, @@ -237,7 +282,7 @@ const generateDateChangeEmailContent = (event, data) => { Thông tin: - Mã yêu cầu: ${request.request_code} - Mã booking: ${booking?.booking_code || request.booking_code} - - Lý do: ${request.admin_notes || 'Không có'} + - Lý do: ${request.admin_notes || "Không có"} Nếu bạn cần hỗ trợ thêm, vui lòng liên hệ với chúng tôi. @@ -258,21 +303,28 @@ const generateFlightCancellationEmailContent = (data) => { const { flight, booking, refund } = data; return { - subject: EMAIL_TEMPLATES.BOOKING_AFFECTED_BY_CANCELLATION.subject.replace('{booking_code}', booking?.booking_code), + subject: EMAIL_TEMPLATES.BOOKING_AFFECTED_BY_CANCELLATION.subject.replace( + "{booking_code}", + booking?.booking_code, + ), body: ` Xin chào, - Chúng tôi xin thông báo rằng chuyến bay ${flight?.flight_number || ''} đã bị hủy bởi hãng hàng không. + Chúng tôi xin thông báo rằng chuyến bay ${flight?.flight_number || ""} đã bị hủy bởi hãng hàng không. Thông tin booking của bạn: - - Mã booking: ${booking?.booking_code || ''} - - Chuyến bay: ${flight?.flight_number || ''} + - Mã booking: ${booking?.booking_code || ""} + - Chuyến bay: ${flight?.flight_number || ""} - ${refund ? ` + ${ + refund + ? ` Yêu cầu hoàn tiền đã được tạo tự động: - Mã hoàn tiền: ${refund.refund_code} - Số tiền hoàn: ${formatCurrency(refund.net_refund_amount)} - ` : ''} + ` + : "" + } Nếu bạn cần hỗ trợ thêm, vui lòng liên hệ với chúng tôi. @@ -291,19 +343,19 @@ const sendEmail = async (to, subject, body, options = {}) => { // Ví dụ: SendGrid, Mailgun, AWS SES, Nodemailer, etc. if (!NOTIFICATIONS.email.enabled) { - console.log('[Email] Notifications disabled, skipping email'); + console.log("[Email] Notifications disabled, skipping email"); return false; } // Mock implementation - console.log('[Email] Sending email:'); + console.log("[Email] Sending email:"); console.log(` From: ${FROM_NAME} <${FROM_EMAIL}>`); console.log(` To: ${to}`); console.log(` Subject: ${subject}`); console.log(` Body: ${body.substring(0, 100)}...`); // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); return true; }; @@ -329,20 +381,28 @@ const createRefundNotification = async (data) => { if (!eventConfig[event]) return; // Get recipient email - priority: guestEmail > refund.guest_email > refund.user_email > booking.contact_email - const recipientEmail = guestEmail || refund?.guest_email || refund?.user_email || booking?.contact_email; + const recipientEmail = + guestEmail || + refund?.guest_email || + refund?.user_email || + booking?.contact_email; if (!recipientEmail) { - console.warn('[Notification] No recipient email found'); + console.warn("[Notification] No recipient email found"); return; } // Generate email content - const content = generateRefundEmailContent(event, { refund, booking, userId }); + const content = generateRefundEmailContent(event, { + refund, + booking, + userId, + }); // Send email await sendEmail(recipientEmail, content.subject, content.body); // Log for admin dashboard if enabled - if (NOTIFICATIONS.admin.alertOnNewRefund && event === 'REFUND_REQUESTED') { + if (NOTIFICATIONS.admin.alertOnNewRefund && event === "REFUND_REQUESTED") { console.log(`[Admin Alert] New refund request: ${refund.refund_code}`); } }; @@ -354,9 +414,12 @@ const createDateChangeNotification = async (data) => { // Check if this event type should send notification const eventConfig = { - DATE_CHANGE_REQUESTED: NOTIFICATIONS.email.sendDateChangeRequestedConfirmation, - DATE_CHANGE_APPROVED: NOTIFICATIONS.email.sendDateChangeApprovedNotification, - DATE_CHANGE_REJECTED: NOTIFICATIONS.email.sendDateChangeRejectedNotification, + DATE_CHANGE_REQUESTED: + NOTIFICATIONS.email.sendDateChangeRequestedConfirmation, + DATE_CHANGE_APPROVED: + NOTIFICATIONS.email.sendDateChangeApprovedNotification, + DATE_CHANGE_REJECTED: + NOTIFICATIONS.email.sendDateChangeRejectedNotification, }; if (!eventConfig[event]) return; @@ -364,12 +427,16 @@ const createDateChangeNotification = async (data) => { // Get recipient email const recipientEmail = request?.user_email || booking?.contact_email; if (!recipientEmail) { - console.warn('[Notification] No recipient email found'); + console.warn("[Notification] No recipient email found"); return; } // Generate email content - const content = generateDateChangeEmailContent(event, { request, booking, userId }); + const content = generateDateChangeEmailContent(event, { + request, + booking, + userId, + }); // Send email await sendEmail(recipientEmail, content.subject, content.body); @@ -378,7 +445,10 @@ const createDateChangeNotification = async (data) => { const createFlightCancellationNotification = async (data) => { const { flight, bookings, refunds } = data; - if (!NOTIFICATIONS.email.enabled || !NOTIFICATIONS.email.sendFlightCancelledNotifications) { + if ( + !NOTIFICATIONS.email.enabled || + !NOTIFICATIONS.email.sendFlightCancelledNotifications + ) { return; } @@ -405,7 +475,9 @@ const createFlightCancellationNotification = async (data) => { const notifyAdminNewRefund = async (refund) => { if (!NOTIFICATIONS.admin.alertOnNewRefund) return; - console.log(`[Admin Notification] New refund request pending: ${refund.refund_code}`); + console.log( + `[Admin Notification] New refund request pending: ${refund.refund_code}`, + ); // TODO: Push notification to admin dashboard // Ví dụ: WebSocket, Push notification, Slack, etc. }; @@ -415,7 +487,7 @@ const checkAndAlertSLABreach = async () => { // TODO: Check refunds that have exceeded SLA // Gửi alert cho admin - console.log('[Admin Notification] Checking SLA breaches...'); + console.log("[Admin Notification] Checking SLA breaches..."); }; // ========================================================= @@ -423,23 +495,113 @@ const checkAndAlertSLABreach = async () => { // ========================================================= const formatCurrency = (amount) => { - return new Intl.NumberFormat('vi-VN', { - style: 'currency', - currency: 'VND', + return new Intl.NumberFormat("vi-VN", { + style: "currency", + currency: "VND", }).format(amount || 0); }; const formatDate = (dateString) => { - if (!dateString) return 'N/A'; - return new Date(dateString).toLocaleString('vi-VN', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleString("vi-VN", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", }); }; +// ========================================================= +// FLIGHT NOTIFICATION FUNCTIONS +// ========================================================= + +/** + * Tạo thông báo chuyến bay và gửi email cho khách hàng + */ +const createFlightNotification = async ({ + event, + flight, + bookings = [], + changes = {}, +}) => { + const { NOTIFICATIONS } = require("../config/refund.config"); + + if ( + !NOTIFICATIONS.email.enabled || + !NOTIFICATIONS.email.sendFlightStatusChangeNotifications + ) { + return; + } + + for (const booking of bookings) { + if (!booking.email) continue; + + const content = generateFlightNotificationContent(event, flight, changes); + + try { + await require("../utils/mailer").sendEmail({ + to: booking.email, + subject: content.subject, + html: content.body, + text: content.text || content.body.replace(/<[^>]+>/g, ""), + }); + + console.log( + `[Flight Notification] Đã gửi email cho booking ${booking.booking_code}`, + ); + } catch (err) { + console.error(`[Flight Notification] Lỗi gửi email:`, err.message); + } + } +}; + +/** + * Tạo nội dung email thông báo chuyến bay + */ +const generateFlightNotificationContent = (event, flight, changes) => { + let subject = `Cập nhật chuyến bay ${flight.flight_number || ""}`; + let body = ""; + + switch (event) { + case "FLIGHT_DELAYED": + subject = `Chuyến bay ${flight.flight_number} bị trễ`; + body = ` +
Kính gửi Quý khách,
+Chuyến bay ${flight.flight_number} từ ${flight.departure_code} → ${flight.arrival_code} đã bị trễ.
+Lý do: ${changes.reason || "Không xác định"}
+Giờ bay mới: ${changes.new_departure_time || flight.departure_time}
+Xin vui lòng kiểm tra lại thông tin trước khi ra sân bay.
+ `.trim(); + break; + + case "FLIGHT_GATE_CHANGED": + subject = `Đổi cổng lên máy bay - ${flight.flight_number}`; + body = ` +Cổng lên máy bay của chuyến bay ${flight.flight_number} đã thay đổi.
+Cổng cũ: ${changes.old_gate || "---"}
+Cổng mới: ${changes.new_gate}
+ `.trim(); + break; + + case "FLIGHT_TIME_CHANGED": + subject = `Thay đổi giờ bay - ${flight.flight_number}`; + body = `Giờ bay của chuyến bay ${flight.flight_number} đã được thay đổi.`; + break; + + default: + subject = `Cập nhật trạng thái chuyến bay ${flight.flight_number}`; + body = `Chuyến bay ${flight.flight_number} có cập nhật mới.`; + } + + return { + subject, + body: `Trân trọng,
Đội ngũ hỗ trợ