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 = ` +

Thông báo chuyến bay

+

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 = ` +

Thông báo thay đổi cổng bay

+

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: `
${body}

Trân trọng,
Đội ngũ hỗ trợ

`, + }; +}; + // ========================================================= // EXPORTS // ========================================================= @@ -458,4 +620,8 @@ module.exports = { sendEmail, formatCurrency, formatDate, + + // FLIGHT NOTIFICATION FUNCTIONS + createFlightNotification, + generateFlightNotificationContent, }; diff --git a/src/services/price-rule.service.js b/src/services/price-rule.service.js new file mode 100644 index 0000000..15baf6c --- /dev/null +++ b/src/services/price-rule.service.js @@ -0,0 +1,266 @@ +"use strict"; + +const pool = require("../config/db"); +const Q = require("../queries/price-rule.queries"); + +// ─── Core: tính giá điều chỉnh ──────────────────────────────────────────────── + +/** + * Lấy tất cả rules áp dụng cho 1 chuyến bay cụ thể vào ngày cụ thể + * Filter theo: ngày, hạng ghế, hãng, tuyến bay + */ +const getApplicableRules = async (date, { seatClass, airlineCode, departureCode, arrivalCode }) => { + const result = await pool.query(Q.GET_ACTIVE_RULES_FOR_DATE, [date]); + + return result.rows.filter(rule => { + // Lọc theo hạng ghế + if (rule.applies_to_class && rule.applies_to_class !== seatClass) return false; + // Lọc theo hãng + if (rule.applies_to_airline_code && rule.applies_to_airline_code !== airlineCode) return false; + // Lọc theo sân bay đi + if (rule.applies_to_departure_code && rule.applies_to_departure_code !== departureCode) return false; + // Lọc theo sân bay đến + if (rule.applies_to_arrival_code && rule.applies_to_arrival_code !== arrivalCode) return false; + return true; + }); +}; + +/** + * Tính giá sau khi áp dụng tất cả rules (stacking) + * Thứ tự: percent rules trước, fixed rules sau + * Giá tối thiểu: 10% base_price (không để âm) + */ +const calcAdjustedPrice = (basePrice, rules) => { + if (!rules || rules.length === 0) { + return { adjusted_price: basePrice, discount: 0, applied_rules: [] }; + } + + let price = parseFloat(basePrice); + const appliedRules = []; + + // 1. Áp dụng percent rules trước (theo priority cao → thấp) + const percentRules = rules + .filter(r => r.adjustment_type === 'percent') + .sort((a, b) => b.priority - a.priority); + + for (const rule of percentRules) { + const delta = Math.round(price * parseFloat(rule.adjustment_value) / 100); + price += delta; + appliedRules.push({ + id: rule.id, + name: rule.name, + type: rule.type, + adjustment: `${rule.adjustment_value > 0 ? '+' : ''}${rule.adjustment_value}%`, + delta, + }); + } + + // 2. Áp dụng fixed rules sau + const fixedRules = rules + .filter(r => r.adjustment_type === 'fixed') + .sort((a, b) => b.priority - a.priority); + + for (const rule of fixedRules) { + const delta = parseFloat(rule.adjustment_value); + price += delta; + appliedRules.push({ + id: rule.id, + name: rule.name, + type: rule.type, + adjustment: `${delta > 0 ? '+' : ''}${delta.toLocaleString('vi-VN')}đ`, + delta, + }); + } + + // Giá tối thiểu = 10% base_price + const minPrice = Math.round(parseFloat(basePrice) * 0.1); + const adjustedPrice = Math.max(Math.round(price), minPrice); + const totalDiscount = adjustedPrice - parseFloat(basePrice); + + return { + adjusted_price: adjustedPrice, + original_price: parseFloat(basePrice), + price_change: totalDiscount, + price_change_pct: Math.round((totalDiscount / parseFloat(basePrice)) * 100), + applied_rules: appliedRules, + }; +}; + +/** + * Áp dụng price rules vào 1 flight object từ search result + * Gắn thêm adjusted_price vào seat + */ +const applyPriceRules = async (flight, departureDate) => { + const rules = await getApplicableRules(departureDate, { + seatClass: flight.seat.class, + airlineCode: flight.airline.code, + departureCode: flight.departure.code, + arrivalCode: flight.arrival.code, + }); + + const priceResult = calcAdjustedPrice(flight.seat.base_price, rules); + + return { + ...flight, + seat: { + ...flight.seat, + // Giá gốc giữ nguyên + base_price: flight.seat.base_price, + + // Giá sau điều chỉnh — FE dùng cái này để hiển thị và thanh toán + adjusted_price: priceResult.adjusted_price, + price_change: priceResult.price_change, + price_change_pct: priceResult.price_change_pct, + + // Tổng tiền toàn đoàn theo giá đã điều chỉnh + total_price: calcTotalPriceAdjusted( + priceResult.adjusted_price, + flight.seat.total_price, + flight.seat.base_price + ), + + // Các rule đang áp dụng (FE dùng để hiển thị badge "Tết +40%") + price_rules: priceResult.applied_rules, + has_price_adjustment: priceResult.applied_rules.length > 0, + }, + }; +}; + +// Helper: scale total_price theo tỉ lệ adjusted/base +const calcTotalPriceAdjusted = (adjustedPrice, originalTotalPrice, basePrice) => { + if (!basePrice || basePrice === 0) return originalTotalPrice; + const ratio = adjustedPrice / parseFloat(basePrice); + return Math.round(parseFloat(originalTotalPrice) * ratio); +}; + +// ─── Admin CRUD ──────────────────────────────────────────────────────────────── + +const VALID_TYPES = ['seasonal', 'holiday', 'weekend', 'event']; + +const validateRule = (data, isUpdate = false) => { + const { name, type, start_date, end_date, adjustment_type, adjustment_value } = data; + + if (!isUpdate) { + if (!name) throw new Error("name là bắt buộc"); + if (!start_date) throw new Error("start_date là bắt buộc (YYYY-MM-DD)"); + if (!end_date) throw new Error("end_date là bắt buộc (YYYY-MM-DD)"); + if (!adjustment_value) throw new Error("adjustment_value là bắt buộc"); + } + + if (type && !VALID_TYPES.includes(type)) { + throw new Error(`type phải là: ${VALID_TYPES.join(', ')}`); + } + if (adjustment_type && !['percent', 'fixed'].includes(adjustment_type)) { + throw new Error("adjustment_type phải là: percent | fixed"); + } + if (start_date && end_date && new Date(end_date) < new Date(start_date)) { + throw new Error("end_date phải >= start_date"); + } + if (adjustment_type === 'percent' && adjustment_value) { + const v = parseFloat(adjustment_value); + if (v < -90 || v > 300) throw new Error("Percent phải trong khoảng -90% đến +300%"); + } +}; + +const getAllRules = async () => { + const result = await pool.query(Q.GET_ALL_RULES); + return result.rows; +}; + +const getRuleById = async (id) => { + const result = await pool.query(Q.GET_RULE_BY_ID, [id]); + if (result.rows.length === 0) throw new Error("Không tìm thấy rule"); + return result.rows[0]; +}; + +const createRule = async (data) => { + validateRule(data); + const { + name, type = 'seasonal', start_date, end_date, + adjustment_type = 'percent', adjustment_value, + applies_to_class = null, applies_to_airline_code = null, + applies_to_departure_code = null, applies_to_arrival_code = null, + priority = 5, is_active = true, + } = data; + + const result = await pool.query(Q.INSERT_RULE, [ + name, type, start_date, end_date, + adjustment_type, parseFloat(adjustment_value), + applies_to_class, applies_to_airline_code, + applies_to_departure_code, applies_to_arrival_code, + priority, is_active, + ]); + return result.rows[0]; +}; + +const updateRule = async (id, data) => { + validateRule(data, true); + await getRuleById(id); // check exists + + const { + 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, + } = data; + + const result = await pool.query(Q.UPDATE_RULE, [ + id, + name || null, + type || null, + start_date || null, + end_date || null, + adjustment_type || null, + adjustment_value ? parseFloat(adjustment_value) : null, + applies_to_class !== undefined ? applies_to_class : undefined, + applies_to_airline_code !== undefined ? applies_to_airline_code : undefined, + applies_to_departure_code !== undefined ? applies_to_departure_code : undefined, + applies_to_arrival_code !== undefined ? applies_to_arrival_code : undefined, + priority || null, + is_active !== undefined ? is_active : null, + ]); + return result.rows[0]; +}; + +const deleteRule = async (id) => { + const result = await pool.query(Q.DELETE_RULE, [id]); + if (result.rows.length === 0) { + throw new Error("Không tìm thấy rule hoặc rule này do hệ thống tạo (không thể xóa)"); + } + return { message: "Đã xóa rule thành công" }; +}; + +const toggleRule = async (id) => { + const result = await pool.query(Q.TOGGLE_RULE, [id]); + if (result.rows.length === 0) throw new Error("Không tìm thấy rule"); + const r = result.rows[0]; + return { message: r.is_active ? "Đã kích hoạt rule" : "Đã tắt rule", ...r }; +}; + +/** + * Preview: xem giá sẽ thay đổi thế nào nếu áp dụng rule vào ngày cụ thể + */ +const previewPrice = async (basePrice, date, options = {}) => { + const rules = await getApplicableRules(date, options); + return { + date, + base_price: parseFloat(basePrice), + ...calcAdjustedPrice(basePrice, rules), + }; +}; + +module.exports = { + // Core + getApplicableRules, + calcAdjustedPrice, + applyPriceRules, + // Admin CRUD + getAllRules, + getRuleById, + createRule, + updateRule, + deleteRule, + toggleRule, + previewPrice, +}; \ No newline at end of file diff --git a/tests/unit/HuongDanChayTest_FlightTrackerAndMemberShip.md b/tests/unit/HuongDanChayTest_FlightTrackerAndMemberShip.md deleted file mode 100644 index 9228c41..0000000 --- a/tests/unit/HuongDanChayTest_FlightTrackerAndMemberShip.md +++ /dev/null @@ -1,154 +0,0 @@ -# Hướng Dẫn Chạy Unit Test — Flight Tracker & Membership - -## Tổng quan - -Bài lab này áp dụng đúng theo mô hình AAA (Arrange – Act – Assert) -như file hướng dẫn C#, nhưng viết bằng **Node.js** và dùng -**node:test** (built-in từ Node 18+, không cần cài thêm gì). - -``` -tests/ -└── unit/ - ├── flight.tracker.test.js ← 7 test cases: getFlightPosition - └── membership.service.test.js ← 10 test cases: getMembershipInfo, - earnPointsAfterBooking, - revokePointsOnCancel -``` - ---- - -## Bước 1 — Copy file test vào project - -Chép 2 file vào thư mục `tests/unit/` trong project của bạn: - -``` -backend-log-function/ -└── tests/ - └── unit/ - ├── flight.tracker.test.js ← file mới - ├── membership.service.test.js ← file mới - ├── flight.service.test.js (có sẵn) - └── ... -``` - ---- - -## Bước 2 — Kiểm tra Node.js version - -```bash -node --version -``` - -Cần **Node 18 trở lên**. Nếu thấp hơn thì nâng Node: - -```bash -# Dùng nvm (nếu đã cài) -nvm install 20 -nvm use 20 -``` - ---- - -## Bước 3 — Chạy từng file test riêng - -```bash -cd backend-log-function - -# Chỉ chạy flight tracker -node --test tests/unit/flight.tracker.test.js - -# Chỉ chạy membership -node --test tests/unit/membership.service.test.js -``` - ---- - -## Bước 4 — Chạy tất cả test cùng lúc - -```bash -node --test tests/unit/*.test.js -``` - -Hoặc dùng script đã có trong package.json: - -```bash -npm test -``` - ---- - -## Bước 5 — Đọc kết quả - -Khi test **PASS**: -``` -✔ getFlightPosition: ném lỗi khi flight_id không tồn tại (3.12ms) -✔ getFlightPosition: status = scheduled khi chưa đến giờ bay (0.45ms) -✔ getMembershipInfo: tier Member khi có 0 điểm (1.02ms) -... -ℹ tests 17 -ℹ pass 17 -ℹ fail 0 -``` - -Khi test **FAIL** (ví dụ logic tính điểm sai): -``` -✖ earnPointsAfterBooking: tính đúng điểm với multiplier Member (x1.0) - AssertionError: pointsEarned phải = 50, thực tế: 45 - Expected: 50 - Actual: 45 -``` - -→ Đọc dòng **Expected** và **Actual** để biết sai ở đâu, rồi sửa code trong `src/services/`. - ---- - -## Giải thích kỹ thuật Mock - -Vì service dùng `pool.query()` để truy vấn database, -ta **không cần chạy DB thật** khi test. Thay vào đó, -inject hàm giả vào `require.cache`: - -``` -┌─────────────────────┐ require.cache[db.js] -│ flight.service.js │ ──→ { query: fakeQuery } ← stub -│ loyalty.service.js │ -└─────────────────────┘ - ↓ - Hàm fakeQuery trả về data mẫu do mình kiểm soát -``` - -Mỗi test có `fakeQuery` riêng → **isolate hoàn toàn**, không phụ thuộc nhau. - ---- - -## Sơ đồ AAA của từng test (ví dụ) - -``` -TEST: "earnPointsAfterBooking với Silver x1.25" - -ARRANGE: fakeQuery trả về multiplier = 1.25 - totalPrice = 1,000,000 VNĐ - -ACT: earnPointsAfterBooking(userId=1, bookingId=103, totalPrice=1_000_000) - -ASSERT: result.pointsEarned === 125 - (floor(1_000_000 / 10_000) * 1.25 = 100 * 1.25 = 125) -``` - ---- - -## Chạy với output dạng TAP (dễ đọc hơn) - -```bash -node --test --test-reporter=tap tests/unit/flight.tracker.test.js -``` - ---- - -## Lưu ý khi bị lỗi - -| Lỗi | Nguyên nhân | Cách sửa | -|-----|-------------|----------| -| `Cannot find module '../../src/...'` | Đặt file test sai thư mục | Đảm bảo file nằm trong `tests/unit/` | -| `TypeError: service.getFlightPosition is not a function` | Flight service chưa export hàm | Thêm `module.exports = { getFlightPosition, ... }` vào cuối service | -| `node:test` not found | Node version < 18 | Nâng Node lên 18+ | diff --git a/tests/unit/admin.flight.service.test.js b/tests/unit/admin.flight.service.test.js index d3077de..acdc60a 100644 --- a/tests/unit/admin.flight.service.test.js +++ b/tests/unit/admin.flight.service.test.js @@ -19,18 +19,12 @@ function loadAdminFlightService(poolMock) { return require(servicePath); } -test("createFlight: tu dong reset sequence va retry khi flights_pkey bi trung", async () => { +test("createFlight: trả lỗi duplicate key khi flights_pkey bị trùng", async () => { let connectCalls = 0; let insertCalls = 0; - let resyncCalls = 0; const poolMock = { query: async (sql) => { - if (String(sql).includes("pg_get_serial_sequence")) { - resyncCalls += 1; - return { rows: [{ setval: 101 }] }; - } - throw new Error(`Unexpected pool.query: ${sql}`); }, connect: async () => { @@ -54,17 +48,10 @@ test("createFlight: tu dong reset sequence va retry khi flights_pkey bi trung", if (normalized.startsWith("INSERT INTO flights")) { insertCalls += 1; - - if (insertCalls === 1) { - const err = new Error('duplicate key value violates unique constraint "flights_pkey"'); - err.code = "23505"; - err.constraint = "flights_pkey"; - throw err; - } - - return { - rows: [{ id: 101, flight_number: "VN889", status: "scheduled" }], - }; + const err = new Error('duplicate key value violates unique constraint "flights_pkey"'); + err.code = "23505"; + err.constraint = "flights_pkey"; + throw err; } if (normalized.startsWith("INSERT INTO flight_seats")) { @@ -80,32 +67,30 @@ test("createFlight: tu dong reset sequence va retry khi flights_pkey bi trung", const adminFlightService = loadAdminFlightService(poolMock); - const result = await adminFlightService.createFlight({ - flight_number: "VN889", - airline_id: 1, - departure_airport_id: 10, - arrival_airport_id: 11, - departure_time: "2026-04-21T19:13:00.000Z", - arrival_time: "2026-04-21T20:13:00.000Z", - duration_minutes: 60, - seats: [ - { class: "economy", total_seats: 50, base_price: 1000000, extra_baggage_options: { 0: 0, 5: 50000, 10: 90000, 20: 160000 } }, - { class: "business", total_seats: 20, base_price: 2000000, extra_baggage_options: { 0: 0, 5: 70000, 10: 130000, 20: 240000 } }, - { class: "first", total_seats: 10, base_price: 3000000, extra_baggage_options: { 0: 0, 5: 90000, 10: 170000, 20: 320000 } }, - ], - }); - - assert.equal(connectCalls, 2); - assert.equal(insertCalls, 2); - assert.equal(resyncCalls, 1); - assert.deepEqual(result, { - flight_id: 101, - flight_number: "VN889", - status: "scheduled", - }); + await assert.rejects( + () => + adminFlightService.createFlight({ + flight_number: "VN889", + airline_id: 1, + departure_airport_id: 10, + arrival_airport_id: 11, + departure_time: "2026-04-21T19:13:00.000Z", + arrival_time: "2026-04-21T20:13:00.000Z", + duration_minutes: 60, + seats: [ + { class: "economy", total_seats: 50, base_price: 1000000, extra_baggage_options: { 0: 0, 5: 50000, 10: 90000, 20: 160000 } }, + { class: "business", total_seats: 20, base_price: 2000000, extra_baggage_options: { 0: 0, 5: 70000, 10: 130000, 20: 240000 } }, + { class: "first", total_seats: 10, base_price: 3000000, extra_baggage_options: { 0: 0, 5: 90000, 10: 170000, 20: 320000 } }, + ], + }), + /duplicate key value violates unique constraint "flights_pkey"/ + ); + + assert.equal(connectCalls, 1); + assert.equal(insertCalls, 1); }); -test("createFlight: seat moi khong nhap gia hanh ly them thi mac dinh bang 0", async () => { +test("createFlight: seat moi khong nhap gia hanh ly them thi dùng mặc định theo hạng ghế", async () => { const insertedSeatValues = []; const poolMock = { @@ -190,11 +175,5 @@ test("createFlight: seat moi khong nhap gia hanh ly them thi mac dinh bang 0", a assert.equal(economySeatValues[4], 1200000); assert.equal(economySeatValues[5], 23); assert.equal(economySeatValues[6], 7); - assert.equal(economySeatValues[7], 0); - assert.deepEqual(JSON.parse(economySeatValues[8]), { - 0: 0, - 5: 0, - 10: 0, - 20: 0, - }); + assert.equal(economySeatValues[7], 250000); }); diff --git a/tests/unit/ancillary.service.test.js b/tests/unit/ancillary.service.test.js new file mode 100644 index 0000000..e6805a5 --- /dev/null +++ b/tests/unit/ancillary.service.test.js @@ -0,0 +1,295 @@ +/** + * ============================================================ + * UNIT TEST — Ancillary Service + * Chạy: node --test tests/unit/ancillary.service.test.js + * ============================================================ + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const SERVICE_PATH = path.resolve(__dirname, '../../src/services/ancillary.service.js'); +const DB_PATH = path.resolve(__dirname, '../../src/config/db.js'); +const Q_PATH = path.resolve(__dirname, '../../src/queries/ancillary.queries.js'); + +function makeClient(fakeQuery) { + return { + query: fakeQuery, + release: () => {} + }; +} + +function loadAncillaryService(fakeQuery) { + + [SERVICE_PATH, DB_PATH, Q_PATH].forEach(p => delete require.cache[p]); + + require.cache[DB_PATH] = { + id: DB_PATH, + filename: DB_PATH, + loaded: true, + exports: { + query: fakeQuery || (async () => ({ rows: [] })), + connect: async () => makeClient(fakeQuery || (async () => ({ rows: [] }))) + } + }; + + require.cache[Q_PATH] = { + id: Q_PATH, + filename: Q_PATH, + loaded: true, + exports: { + GET_ANCILLARY_OPTIONS: 'GET_ANCILLARY_OPTIONS', + GET_ANCILLARIES_BY_BOOKING: 'GET_ANCILLARIES_BY_BOOKING', + GET_ANCILLARY_TOTAL: 'GET_ANCILLARY_TOTAL', + CHECK_PASSENGER_IN_BOOKING: 'CHECK_PASSENGER_IN_BOOKING', + GET_ANCILLARY_OPTION_BY_ID: 'GET_ANCILLARY_OPTION_BY_ID', + CHECK_DUPLICATE_ANCILLARY: 'CHECK_DUPLICATE_ANCILLARY', + INSERT_ANCILLARY: 'INSERT_ANCILLARY', + CANCEL_ANCILLARY: 'CANCEL_ANCILLARY' + } + }; + + return require(SERVICE_PATH); +} + +test('getAncillaryOptions: throw lỗi khi type không hợp lệ', async () => { + + const service = loadAncillaryService(); + + await assert.rejects( + () => service.getAncillaryOptions('premium'), + /type phải/ + ); +}); + +test('getAncillaryOptions: group đúng theo type', async () => { + + const fakeQuery = async () => ({ + rows: [ + { + id: 1, + type: 'meal', + name: 'Meal A', + description: 'Hot meal', + price: '10', + unit: 'set', + meta: {} + } + ] + }); + + const service = loadAncillaryService(fakeQuery); + + const result = await service.getAncillaryOptions(); + + assert.ok(result.types.includes('meal')); + assert.equal(result.options.meal.length, 1); +}); + +test('addAncillary: throw lỗi khi thiếu passenger_id', async () => { + + const service = loadAncillaryService(); + + await assert.rejects( + () => service.addAncillary(1, { + ancillary_option_id: 1 + }), + /passenger_id/ + ); +}); + +test('addAncillary: throw lỗi khi quantity < 1', async () => { + + const service = loadAncillaryService(); + + await assert.rejects( + () => service.addAncillary(1, { + passenger_id: 1, + ancillary_option_id: 1, + quantity: 0 + }), + /quantity/ + ); +}); + +test('addAncillary: throw lỗi khi passenger không thuộc booking', async () => { + + const fakeQuery = async (query) => { + + if (query === 'BEGIN' || query === 'ROLLBACK') { + return { rows: [] }; + } + + return { rows: [] }; + }; + + const service = loadAncillaryService(fakeQuery); + + await assert.rejects( + () => service.addAncillary(1, { + passenger_id: 1, + ancillary_option_id: 1 + }), + /Hành khách không thuộc booking này/ + ); +}); + +test('addAncillary: thêm ancillary thành công', async () => { + + let call = 0; + + const fakeQuery = async (query) => { + + call++; + + if (query === 'BEGIN' || query === 'COMMIT') { + return { rows: [] }; + } + + if (call === 2) { + return { + rows: [{ id: 1 }] + }; + } + + if (call === 3) { + return { + rows: [{ + id: 1, + is_active: true, + name: 'Extra Baggage', + type: 'baggage', + price: '20' + }] + }; + } + + if (call === 4) { + return { + rows: [{ + id: 99 + }] + }; + } + + return { + rows: [{ + ancillary_total: '20' + }] + }; + }; + + const service = loadAncillaryService(fakeQuery); + + const result = await service.addAncillary(1, { + passenger_id: 1, + ancillary_option_id: 1, + quantity: 1 + }); + + assert.equal(result.message, 'Đã thêm dịch vụ bổ sung thành công'); + assert.equal(result.total_price, 20); +}); + +test('removeAncillary: throw lỗi khi ancillary không tồn tại', async () => { + + const fakeQuery = async (query) => { + + if (query === 'BEGIN' || query === 'ROLLBACK') { + return { rows: [] }; + } + + return { rows: [] }; + }; + + const service = loadAncillaryService(fakeQuery); + + await assert.rejects( + () => service.removeAncillary(1, 99), + /Không tìm thấy dịch vụ bổ sung/ + ); +}); + +test('removeAncillary: remove ancillary thành công', async () => { + + let call = 0; + + const fakeQuery = async (query) => { + + call++; + + if (query === 'BEGIN' || query === 'COMMIT') { + return { rows: [] }; + } + + if (call === 2) { + return { + rows: [{ id: 1 }] + }; + } + + return { + rows: [{ + ancillary_total: '0' + }] + }; + }; + + const service = loadAncillaryService(fakeQuery); + + const result = await service.removeAncillary(1, 1); + + assert.equal(result.message, 'Đã huỷ dịch vụ bổ sung'); +}); + +test('getBookingTotal: throw lỗi khi booking không tồn tại', async () => { + + const fakeQuery = async (query) => { + + if (String(query).includes('SELECT total_price')) { + return { rows: [] }; + } + + return { + rows: [{ + ancillary_total: '0' + }] + }; + }; + + const service = loadAncillaryService(fakeQuery); + + await assert.rejects( + () => service.getBookingTotal(999), + /Không tìm thấy booking/ + ); +}); + +test('getBookingTotal: tính grand_total đúng', async () => { + + const fakeQuery = async (query) => { + + if (String(query).includes('SELECT total_price')) { + return { + rows: [{ + total_price: '100' + }] + }; + } + + return { + rows: [{ + ancillary_total: '20' + }] + }; + }; + + const service = loadAncillaryService(fakeQuery); + + const result = await service.getBookingTotal(1); + + assert.equal(result.ticket_price, 100); + assert.equal(result.ancillary_total, 20); + assert.equal(result.grand_total, 120); +}); diff --git a/tests/unit/flight.service.test.js b/tests/unit/flight.service.test.js index e44e809..345becc 100644 --- a/tests/unit/flight.service.test.js +++ b/tests/unit/flight.service.test.js @@ -21,16 +21,15 @@ function loadFlightService(queryImpl) { return require(servicePath); } -test('searchFlights: báo lỗi nếu điểm đi và điểm đến trùng nhau', async () => { +test('searchFlights: báo lỗi nếu thiếu mã sân bay đi', async () => { const flightService = loadFlightService(); await assert.rejects( () => flightService.searchFlights({ - departure_code: 'SGN', - arrival_code: 'SGN', + arrival_code: 'HAN', departure_date: '2099-12-31', seat_class: 'economy', }), - /Điểm đi và điểm đến không được trùng nhau/ + /Mã sân bay đi là bắt buộc/ ); }); @@ -43,7 +42,7 @@ test('searchFlights: báo lỗi nếu seat_class không hợp lệ', async () => departure_date: '2099-12-31', seat_class: 'premium', }), - /seat_class phải là một trong/ + /seat_class phải là economy, business hoặc first/ ); }); @@ -100,7 +99,7 @@ test('searchFlights: trả danh sách chuyến bay đã format đúng', async () assert.equal(result.outbound_flights[0].flight_number, 'VN123'); assert.equal(result.outbound_flights[0].seat.total_price, 175); assert.equal(result.outbound_flights[0].seat.price_breakdown.child_price, 75); - assert.equal(result.outbound_flights[0].seat.extra_baggage_options.length, 4); - assert.equal(result.outbound_flights[0].seat.extra_baggage_options[1].price_per_person, 120000); - assert.equal(result.outbound_flights[0].seat.extra_baggage_options[2].price_per_person, 210000); + assert.equal(result.outbound_flights[0].seat.extra_baggage_options.length, 5); + assert.equal(result.outbound_flights[0].seat.extra_baggage_options[1].price_per_person, 10); + assert.equal(result.outbound_flights[0].seat.extra_baggage_options[2].price_per_person, 20); }); diff --git a/tests/unit/wishlist.service.test.js b/tests/unit/wishlist.service.test.js new file mode 100644 index 0000000..f393baf --- /dev/null +++ b/tests/unit/wishlist.service.test.js @@ -0,0 +1,232 @@ +/** + * ============================================================ + * UNIT TEST — Wishlist Service + * Chạy: node --test tests/unit/wishlist.service.test.js + * ============================================================ + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const SERVICE_PATH = path.resolve(__dirname, '../../src/services/wishlist.service.js'); +const DB_PATH = path.resolve(__dirname, '../../src/config/db.js'); +const Q_PATH = path.resolve(__dirname, '../../src/queries/wishlist.queries.js'); + +function loadWishlistService(fakeQuery) { + [SERVICE_PATH, DB_PATH, Q_PATH].forEach(p => delete require.cache[p]); + + require.cache[DB_PATH] = { + id: DB_PATH, + filename: DB_PATH, + loaded: true, + exports: { + query: fakeQuery || (async () => ({ rows: [] })) + } + }; + + require.cache[Q_PATH] = { + id: Q_PATH, + filename: Q_PATH, + loaded: true, + exports: { + FIND_ACTIVE_FLIGHT: 'FIND_ACTIVE_FLIGHT', + FIND_WISHLIST_BY_USER: 'FIND_WISHLIST_BY_USER', + INSERT_WISHLIST_USER: 'INSERT_WISHLIST_USER', + DELETE_WISHLIST_BY_USER: 'DELETE_WISHLIST_BY_USER', + SELECT_WISHLIST_BY_USER: 'SELECT_WISHLIST_BY_USER' + } + }; + + return require(SERVICE_PATH); +} + +function makeWishlistRow() { + return { + id: 1, + seat_class: 'economy', + created_at: new Date().toISOString(), + flight_id: 42, + flight_number: 'VN123', + departure_time: new Date().toISOString(), + arrival_time: new Date().toISOString(), + duration_minutes: 120, + flight_status: 'scheduled', + airline_code: 'VN', + airline_name: 'Vietnam Airlines', + logo_url: 'logo.png', + logo_dark: 'dark.png', + logo_light: 'light.png', + dep_code: 'SGN', + dep_city: 'Ho Chi Minh', + arr_code: 'HAN', + arr_city: 'Ha Noi', + base_price: '1200000', + available_seats: '20' + }; +} + +test('addToWishlist: throw lỗi khi flight_id không hợp lệ', async () => { + + const service = loadWishlistService(); + + await assert.rejects( + () => service.addToWishlist(1, 0, 'economy'), + /flight_id không hợp lệ/ + ); +}); + +test('addToWishlist: throw lỗi khi seat_class không hợp lệ', async () => { + + const service = loadWishlistService(); + + await assert.rejects( + () => service.addToWishlist(1, 42, 'vip'), + /seat_class/ + ); +}); + +test('addToWishlist: throw lỗi khi flight không tồn tại', async () => { + + const fakeQuery = async () => ({ rows: [] }); + + const service = loadWishlistService(fakeQuery); + + await assert.rejects( + () => service.addToWishlist(1, 42, 'economy'), + /Chuyến bay không tồn tại/ + ); +}); + +test('addToWishlist: thêm wishlist thành công', async () => { + + let call = 0; + + const fakeQuery = async () => { + call++; + + if (call === 1) return { rows: [{ id: 42 }] }; + if (call === 2) return { rows: [] }; + if (call === 3) { + return { + rows: [ + { + id: 1, + user_id: 1, + flight_id: 42, + seat_class: 'economy' + } + ] + }; + } + + return { rows: [] }; + }; + + const service = loadWishlistService(fakeQuery); + + const result = await service.addToWishlist(1, 42, 'economy'); + + assert.equal(result.message, 'Đã thêm vào danh sách yêu thích'); + assert.equal(result.item.flight_id, 42); +}); + +test('removeFromWishlist: throw lỗi khi item không tồn tại', async () => { + + const fakeQuery = async () => ({ rows: [] }); + + const service = loadWishlistService(fakeQuery); + + await assert.rejects( + () => service.removeFromWishlist(1, 42, 'economy'), + /Không tìm thấy chuyến bay/ + ); +}); + +test('removeFromWishlist: xóa wishlist thành công', async () => { + + const fakeQuery = async () => ({ + rows: [{ id: 1 }] + }); + + const service = loadWishlistService(fakeQuery); + + const result = await service.removeFromWishlist(1, 42, 'economy'); + + assert.equal(result.message, 'Đã xóa khỏi danh sách yêu thích'); +}); + +test('getWishlist: trả về đúng total và items', async () => { + + const fakeQuery = async () => ({ + rows: [makeWishlistRow()] + }); + + const service = loadWishlistService(fakeQuery); + + const result = await service.getWishlist(1); + + assert.equal(result.total, 1); + assert.equal(result.items.length, 1); +}); + +test('getWishlist: object trả về đủ fields', async () => { + + const fakeQuery = async () => ({ + rows: [makeWishlistRow()] + }); + + const service = loadWishlistService(fakeQuery); + + const result = await service.getWishlist(1); + + const item = result.items[0]; + + ['id', 'seat_class', 'flight'].forEach(key => { + assert.ok(key in item); + }); + + ['flight_number', 'departure', 'arrival', 'airline'] + .forEach(key => { + assert.ok(key in item.flight); + }); +}); + +test('syncWishlist: skip khi localItems rỗng', async () => { + + const service = loadWishlistService(); + + const result = await service.syncWishlist(1, []); + + assert.equal(result.synced, 0); + assert.equal(result.skipped, 0); +}); + +test('syncWishlist: sync thành công item hợp lệ', async () => { + + let call = 0; + + const fakeQuery = async () => { + call++; + + if (call === 1) { + return { rows: [{ id: 42 }] }; + } + + return { + rowCount: 1, + rows: [{ id: 1 }] + }; + }; + + const service = loadWishlistService(fakeQuery); + + const result = await service.syncWishlist(1, [ + { + flight_id: 42, + seat_class: 'economy' + } + ]); + + assert.equal(result.synced, 1); +});