From 03b495eb15cf5d90fef70b2cd474c1d6a466a800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Mon, 25 May 2026 01:25:12 +0700 Subject: [PATCH 01/11] update 25/5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit đã thêm chức năng hủy vé và chuyến bay đã quá cũ thì sẽ ẩn không tìm thấy nữa --- src/app.js | 2 +- src/services/booking.service.js | 81 ++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/app.js b/src/app.js index d723dee..23ca0d7 100644 --- a/src/app.js +++ b/src/app.js @@ -14,7 +14,7 @@ const loyaltyRoutes = require('./routes/loyalty.routes'); const refundRoutes = require('./routes/refund.routes'); const dateChangeRoutes = require('./routes/date-change.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"); require("./scripts/Loyalty.cron"); // Loyalty annual reset cron job diff --git a/src/services/booking.service.js b/src/services/booking.service.js index 065a8d1..bf1e268 100644 --- a/src/services/booking.service.js +++ b/src/services/booking.service.js @@ -437,4 +437,83 @@ const expireHeldBookings = async () => { // ... (giữ nguyên) }; -module.exports = { createBooking, getBookingDetail, getMyBookings, cancelBooking, expireHeldBookings }; \ No newline at end of file + +/** + * 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" + // Buffer 3 tiếng — khớp với cấp 3 ở search query + const completedResult = await client.query(` + UPDATE flights + SET status = 'completed', + updated_at = NOW() + WHERE departure_time < NOW() - INTERVAL '3 hours' + AND status NOT IN ('cancelled', 'completed') + RETURNING id, flight_number + `); + + 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, autoCompleteFlights }; \ No newline at end of file From 64c31deb4403df0f54bbcc98f8b78e7f0e3aef15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Mon, 25 May 2026 14:44:25 +0700 Subject: [PATCH 02/11] update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DONE chức năng tăng và giảm giá theo mùa hoặc lễ hội --- src/controllers/rule.controller.js | 74 ++++++++ src/queries/rule.queries.js | 78 +++++++++ src/routes/admin.routes.js | 14 +- src/services/flight.service.js | 20 ++- src/services/rule.service.js | 266 +++++++++++++++++++++++++++++ 5 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 src/controllers/rule.controller.js create mode 100644 src/queries/rule.queries.js create mode 100644 src/services/rule.service.js diff --git a/src/controllers/rule.controller.js b/src/controllers/rule.controller.js new file mode 100644 index 0000000..a25f7a2 --- /dev/null +++ b/src/controllers/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/queries/rule.queries.js b/src/queries/rule.queries.js new file mode 100644 index 0000000..7cc04e6 --- /dev/null +++ b/src/queries/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/routes/admin.routes.js b/src/routes/admin.routes.js index 1bff52a..c5e399c 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -13,7 +13,8 @@ const adminFlightCancellationController = require("../controllers/admin/flight-c const adminUserController = require("../controllers/admin/user.controller"); const adminChatController = require("../controllers/admin/chat.controller"); -const adminCronController = require("../controllers/admin/cron.controller"); +const adminCronController = require("../controllers/admin/cron.controller"); +const adminPriceRuleController = require("../controllers/admin/price-rule.controller"); // Tất cả routes admin: phải đăng nhập + role = 'admin' router.use(authenticate, authorize("admin")); @@ -103,4 +104,13 @@ router.get("/chat/conversations/:id", adminChatController.getSupportConversation router.post("/chat/conversations/:id/message", adminChatController.replySupportConversation); router.patch("/chat/conversations/:id/status", adminChatController.updateSupportConversationStatus); -module.exports = router; +// Price Rules (Dynamic Pricing) +router.get("/price-rules/preview", adminPriceRuleController.previewPrice); +router.get("/price-rules", adminPriceRuleController.getAllRules); +router.get("/price-rules/:id", adminPriceRuleController.getRuleById); +router.post("/price-rules", adminPriceRuleController.createRule); +router.put("/price-rules/:id", adminPriceRuleController.updateRule); +router.delete("/price-rules/:id", adminPriceRuleController.deleteRule); +router.patch("/price-rules/:id/toggle", adminPriceRuleController.toggleRule); + +module.exports = router; \ No newline at end of file diff --git a/src/services/flight.service.js b/src/services/flight.service.js index d979643..617b923 100644 --- a/src/services/flight.service.js +++ b/src/services/flight.service.js @@ -212,8 +212,13 @@ const searchFlights = async (params) => { airline_code, departure_city, arrival_city, }; - const outboundRows = await queryFlights(baseParams); - const outboundFlights = formatFlights(outboundRows, a, c, i); + const outboundRows = await queryFlights(baseParams); + const outboundFlightsRaw = formatFlights(outboundRows, a, c, i); + + // Áp dụng price rules (tăng/giảm giá theo mùa, lễ hội) + const outboundFlights = await Promise.all( + outboundFlightsRaw.map(f => priceRuleService.applyPriceRules(f, departure_date)) + ); let returnFlights = null; if (return_date) { @@ -223,14 +228,17 @@ const searchFlights = async (params) => { arrival_code: departure_code, departure_date: return_date, }); - returnFlights = formatFlights(returnRows, a, c, i); + const returnFlightsRaw = formatFlights(returnRows, a, c, i); + returnFlights = await Promise.all( + returnFlightsRaw.map(f => priceRuleService.applyPriceRules(f, return_date)) + ); } return { outbound_flights: outboundFlights, - return_flights: returnFlights, - total_outbound: outboundFlights.length, - total_return: returnFlights ? returnFlights.length : 0, + return_flights: returnFlights, + total_outbound: outboundFlights.length, + total_return: returnFlights ? returnFlights.length : 0, }; }; diff --git a/src/services/rule.service.js b/src/services/rule.service.js new file mode 100644 index 0000000..15baf6c --- /dev/null +++ b/src/services/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 From 5f67c74793403c963b8edbf05a56abafcf752bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Tue, 26 May 2026 13:29:45 +0700 Subject: [PATCH 03/11] update price-rule --- package-lock.json | 1 + .../{rule.controller.js => admin/price-rule.controller.js} | 0 src/queries/{rule.queries.js => price-rule.queries.js} | 0 src/services/booking.service.js | 2 +- src/services/{rule.service.js => price-rule.service.js} | 0 5 files changed, 2 insertions(+), 1 deletion(-) rename src/controllers/{rule.controller.js => admin/price-rule.controller.js} (100%) rename src/queries/{rule.queries.js => price-rule.queries.js} (100%) rename src/services/{rule.service.js => price-rule.service.js} (100%) diff --git a/package-lock.json b/package-lock.json index b019810..60154ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1742,6 +1742,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/controllers/rule.controller.js b/src/controllers/admin/price-rule.controller.js similarity index 100% rename from src/controllers/rule.controller.js rename to src/controllers/admin/price-rule.controller.js diff --git a/src/queries/rule.queries.js b/src/queries/price-rule.queries.js similarity index 100% rename from src/queries/rule.queries.js rename to src/queries/price-rule.queries.js diff --git a/src/services/booking.service.js b/src/services/booking.service.js index 8a34e9f..a967ce5 100644 --- a/src/services/booking.service.js +++ b/src/services/booking.service.js @@ -491,7 +491,7 @@ const autoCompleteFlights = async () => { 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:`, diff --git a/src/services/rule.service.js b/src/services/price-rule.service.js similarity index 100% rename from src/services/rule.service.js rename to src/services/price-rule.service.js From b15efdee8a58892a001842ae62ba67930a74b109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Tue, 26 May 2026 14:03:51 +0700 Subject: [PATCH 04/11] Update price-rule.controller.js --- src/controllers/admin/price-rule.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/admin/price-rule.controller.js b/src/controllers/admin/price-rule.controller.js index a25f7a2..a800953 100644 --- a/src/controllers/admin/price-rule.controller.js +++ b/src/controllers/admin/price-rule.controller.js @@ -1,6 +1,6 @@ "use strict"; -const priceRuleService = require("../services/price-rule.service"); +const priceRuleService = require("../../services/price-rule.service"); /** GET /api/admin/price-rules */ const getAllRules = async (req, res) => { From a7ef6442636cb065ee05944a6c1ac6c3a2b7993b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Wed, 27 May 2026 16:28:54 +0700 Subject: [PATCH 05/11] Update booking.service.js --- src/services/booking.service.js | 52 ++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/services/booking.service.js b/src/services/booking.service.js index a967ce5..18a9c7f 100644 --- a/src/services/booking.service.js +++ b/src/services/booking.service.js @@ -468,7 +468,57 @@ 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(); + } }; From c0b3e2044c65b3f91dd44cd862d88bb5124e8a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Thu, 28 May 2026 02:08:29 +0700 Subject: [PATCH 06/11] Recommendation flights update --- src/controllers/flight.controller.js | 22 +-- src/services/flight.service.js | 246 ++++++++++++++++++++++++--- 2 files changed, 227 insertions(+), 41 deletions(-) diff --git a/src/controllers/flight.controller.js b/src/controllers/flight.controller.js index 8b6cef2..86adc1e 100644 --- a/src/controllers/flight.controller.js +++ b/src/controllers/flight.controller.js @@ -120,21 +120,15 @@ const getFlightRecommendations = async (req, res) => { // Lấy tham số từ query string (?from=SGN&to=HAN&limit=10) const { from, to, limit = 10 } = req.query; - // === LẤY USER ID (hỗ trợ cả đăng nhập JWT lẫn test qua query) === - const userId = req.user?.id || req.query.userId || null; - - // Validation - if (!from || !to) { - return res.status(400).json({ - error: "Thiếu tham số 'from' hoặc 'to' (mã sân bay)" - }); - } + const userId = req.user?.id || req.query.userId || null; + const sessionId = req.headers['x-session-id'] || req.query.session_id || null; const recommendations = await flightService.recommendFlights({ - userId: userId, - fromAirport: from.toUpperCase(), - toAirport: to.toUpperCase(), - limit: parseInt(limit) || 10 + userId, + sessionId, + fromAirport: from ? from.toUpperCase() : null, + toAirport: to ? to.toUpperCase() : null, + limit: parseInt(limit) || 10, }); res.json({ @@ -189,4 +183,4 @@ module.exports = { getSeatMap, getFlightRecommendations, getFlightPosition, -}; +}; \ No newline at end of file diff --git a/src/services/flight.service.js b/src/services/flight.service.js index 617b923..1c80beb 100644 --- a/src/services/flight.service.js +++ b/src/services/flight.service.js @@ -2,49 +2,240 @@ const pool = require('../config/db'); const QF = require('../queries/flight.queries'); -const recommendFlights = async ({ userId, fromAirport, toAirport, limit = 15 }) => { +/** + * Lưu lịch sử tìm kiếm (fire-and-forget, không block search) + */ +const saveSearchHistory = async ({ + userId, sessionId, + departureCode, arrivalCode, departureDate, returnDate, + seatClass, adults, children, infants, + resultsCount, minPriceFound, +}) => { + try { + await pool.query( + `INSERT INTO search_history ( + user_id, session_id, + departure_code, arrival_code, departure_date, return_date, + seat_class, adults, children, infants, + results_count, min_price_found + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, + [ + userId || null, + sessionId || null, + departureCode, arrivalCode, + departureDate, returnDate || null, + seatClass, + adults, children, infants, + resultsCount || 0, + minPriceFound || null, + ] + ); + } catch (err) { + // Không để lỗi log làm hỏng search + console.error("[SearchHistory] Lỗi lưu lịch sử:", err.message); + } +}; + +/** + * Recommendation Flights + * 1. Lưu lịch sử tìm kiếm: số tiền bỏ ra, điểm đến yêu thích, thời gian bay + * 2. Ưu tiên chuyến buổi sáng, không quá cảnh, vé < 5tr + * 3. Tự động lọc và ưu tiên theo tiêu chí + * 4. Nếu chưa có lịch sử → TopBuy + * 5. 10 chuyến gần nhất theo khung thời gian + */ +const recommendFlights = async ({ userId, sessionId, fromAirport, toAirport, limit = 10 }) => { + + // ── BƯỚC 1: Phân tích lịch sử tìm kiếm + booking ──────────────────────── + let preferredDestinations = []; + let preferredDepartures = []; + let preferredHours = null; + let avgSpending = null; + let hasHistory = false; + + if (userId || sessionId) { + // Lịch sử tìm kiếm + const searchHistSQL = userId + ? `WHERE user_id = ${parseInt(userId)}` + : `WHERE session_id = '${sessionId}'`; + + const searchResult = await pool.query(` + SELECT + arrival_code, + departure_code, + COUNT(*) AS search_count, + AVG(min_price_found) AS avg_min_price, + -- Giờ bay ưa thích: phân tích từ departure_date không đủ + -- dùng booking history thay thế + MIN(seat_class) AS preferred_class + FROM search_history + ${searchHistSQL} + AND created_at > NOW() - INTERVAL '90 days' + GROUP BY arrival_code, departure_code + ORDER BY search_count DESC + LIMIT 5 + `); + + if (searchResult.rows.length > 0) { + hasHistory = true; + preferredDestinations = searchResult.rows.map(r => r.arrival_code); + preferredDepartures = searchResult.rows.map(r => r.departure_code); + avgSpending = parseFloat(searchResult.rows[0].avg_min_price) || null; + } + + // Lịch sử booking (nếu user đăng nhập) + if (userId) { + const bookingHistory = await pool.query(` + SELECT + arr.code AS arr_code, + dep.code AS dep_code, + COUNT(*) AS trip_count, + AVG(b.total_price) AS avg_price, + AVG(EXTRACT(HOUR FROM f.departure_time)) AS avg_dep_hour + FROM bookings b + JOIN flights f ON f.id = b.outbound_flight_id + JOIN airports dep ON dep.id = f.departure_airport_id + JOIN airports arr ON arr.id = f.arrival_airport_id + WHERE b.user_id = $1 + AND b.status IN ('confirmed', 'completed') + GROUP BY arr.code, dep.code + ORDER BY trip_count DESC + LIMIT 5 + `, [userId]); + + if (bookingHistory.rows.length > 0) { + hasHistory = true; + preferredHours = parseFloat(bookingHistory.rows[0].avg_dep_hour) || null; + avgSpending = parseFloat(bookingHistory.rows[0].avg_price) || avgSpending; + + // Merge destinations từ booking vào + for (const r of bookingHistory.rows) { + if (!preferredDestinations.includes(r.arr_code)) { + preferredDestinations.push(r.arr_code); + } + } + } + } + } + + // ── BƯỚC 2: Nếu không có lịch sử → TopBuy ──────────────────────────────── + if (!hasHistory) { + const topBuy = await pool.query(` + SELECT + dep.code AS dep_code, + arr.code AS arr_code, + COUNT(b.id) AS booking_count + FROM bookings b + JOIN flights f ON f.id = b.outbound_flight_id + JOIN airports dep ON dep.id = f.departure_airport_id + JOIN airports arr ON arr.id = f.arrival_airport_id + WHERE b.status IN ('confirmed', 'completed') + AND f.departure_time > NOW() + GROUP BY dep.code, arr.code + ORDER BY booking_count DESC + LIMIT 5 + `); + preferredDestinations = topBuy.rows.map(r => r.arr_code); + } + + // ── BƯỚC 3: Scoring ─────────────────────────────────────────────────────── + const destSQL = preferredDestinations.length > 0 + ? `CASE WHEN arr.code = ANY(ARRAY['${preferredDestinations.join("','")}']) THEN 30 ELSE 0 END` + : '0'; + + const avgHourSQL = preferredHours !== null + ? `CASE WHEN ABS(EXTRACT(HOUR FROM f.departure_time) - ${preferredHours}) <= 2 THEN 10 ELSE 0 END` + : '0'; + + // Giá phù hợp với mức chi trung bình của user + const budgetSQL = avgSpending + ? `CASE WHEN fs.base_price <= ${avgSpending * 1.2} THEN 10 ELSE 0 END` + : `CASE WHEN fs.base_price < 5000000 THEN 15 ELSE 0 END`; + const query = ` SELECT - f.id AS flight_id, + f.id AS flight_id, f.flight_number, f.departure_time, f.arrival_time, f.duration_minutes, f.status, - al.id AS airline_id, - al.code AS airline_code, - al.name AS airline_name, - al.logo_url AS airline_logo, - al.logo_dark AS airline_logo_dark, - al.logo_light AS airline_logo_light, - dep.id AS departure_airport_id, - dep.code AS departure_code, - dep.city AS departure_city, - dep.name AS departure_airport_name, - arr.id AS arrival_airport_id, - arr.code AS arrival_code, - arr.city AS arrival_city, - arr.name AS arrival_airport_name, - fs.class AS seat_class, + al.id AS airline_id, + al.code AS airline_code, + al.name AS airline_name, + al.logo_url AS airline_logo, + al.logo_dark AS airline_logo_dark, + al.logo_light AS airline_logo_light, + dep.id AS departure_airport_id, + dep.code AS departure_code, + dep.city AS departure_city, + dep.name AS departure_airport_name, + arr.id AS arrival_airport_id, + arr.code AS arrival_code, + arr.city AS arrival_city, + arr.name AS arrival_airport_name, + fs.class AS seat_class, fs.total_seats, fs.available_seats, fs.base_price, fs.baggage_included_kg, fs.carry_on_kg, - fs.extra_baggage_price + fs.extra_baggage_price, + + ( + ${destSQL} + + CASE WHEN EXTRACT(HOUR FROM f.departure_time) BETWEEN 5 AND 11 THEN 20 ELSE 0 END + + CASE WHEN EXTRACT(HOUR FROM f.departure_time) >= 22 + OR EXTRACT(HOUR FROM f.departure_time) <= 4 THEN -10 ELSE 0 END + + CASE WHEN f.duration_minutes < 300 THEN 15 ELSE 0 END + + ${budgetSQL} + + ${avgHourSQL} + ) AS score + 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 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 LEFT JOIN flight_seats fs ON fs.flight_id = f.id AND fs.class = 'economy' - WHERE f.status = 'scheduled' - AND f.is_active = true - AND f.departure_time > NOW() - ORDER BY f.departure_time ASC - LIMIT $1`; + + WHERE f.status = 'scheduled' + AND f.is_active = TRUE + AND fs.available_seats > 0 + AND f.departure_time BETWEEN NOW() - INTERVAL '3 hours' + AND NOW() + INTERVAL '10 days' + ${fromAirport ? `AND dep.code = '${fromAirport}'` : ''} + ${toAirport ? `AND arr.code = '${toAirport}'` : ''} + + ORDER BY score DESC, f.departure_time ASC + LIMIT $1 + `; const { rows } = await pool.query(query, [limit]); - return formatFlights(rows, 1, 0, 0); + const flights = formatFlights(rows, 1, 0, 0); + + // ── BƯỚC 4: Gắn badges + metadata ──────────────────────────────────────── + return flights.map((f, i) => { + const score = parseInt(rows[i].score) || 0; + const hour = new Date(f.departure.time).getHours(); + const badges = []; + + if (preferredDestinations.includes(f.arrival.code)) { + badges.push(hasHistory + ? { label: 'Điểm đến yêu thích', color: 'blue' } + : { label: 'Tuyến hot', color: 'orange' }); + } + if (hour >= 5 && hour <= 11) badges.push({ label: 'Chuyến sáng', color: 'yellow' }); + if (f.duration_minutes < 300) badges.push({ label: 'Bay thẳng', color: 'green' }); + if (f.seat && f.seat.base_price < 5000000) badges.push({ label: 'Giá tốt < 5tr', color: 'green' }); + + return { + ...f, + score, + badges, + is_recommended: score > 20, + recommendation_reason: hasHistory ? 'personalized' : 'top_buy', + }; + }); }; const formatDuration = (minutes) => { @@ -521,6 +712,7 @@ const getPriceCalendar = async (params = {}) => { module.exports = { recommendFlights, + saveSearchHistory, searchFlights, getAirports, getAirlines, From b617ffdb6333c0d4505bae0f5fd67248a8dade5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Thu, 28 May 2026 15:27:52 +0700 Subject: [PATCH 07/11] Flight brands DONE AS-113 --- src/controllers/flight-brand.controller.js | 42 ++++ src/routes/flight.routes.js | 4 +- src/services/flight-brand.service.js | 229 +++++++++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/controllers/flight-brand.controller.js create mode 100644 src/services/flight-brand.service.js 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/routes/flight.routes.js b/src/routes/flight.routes.js index eb8d001..4a9ec40 100644 --- a/src/routes/flight.routes.js +++ b/src/routes/flight.routes.js @@ -1,6 +1,7 @@ const express = require("express"); const router = express.Router(); -const flightController = require("../controllers/flight.controller"); +const flightController = require("../controllers/flight.controller"); +const flightBrandController = require("../controllers/flight-brand.controller"); router.get("/search", flightController.searchFlights); router.get("/airports", flightController.getAirports); @@ -8,6 +9,7 @@ router.get("/airlines", flightController.getAirlines); router.get("/alternatives", flightController.getAlternativeFlights); router.get("/price-calendar", flightController.getPriceCalendar); router.get("/recommendations", flightController.getFlightRecommendations); +router.get("/brand-combinations", flightBrandController.getBrandCombinations); router.get("/:id/seat-map", flightController.getSeatMap); router.get("/:id/position", flightController.getFlightPosition); router.get("/:id", flightController.getFlightById); 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 From 296817237fd2a98efd8ae28b89a197250dbdc321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Thu, 28 May 2026 19:21:58 +0700 Subject: [PATCH 08/11] a --- .gitignore | 3 +- src/services/admin/flight.service.js | 64 ++- src/services/flight.service.js | 1 + ...gDanChayTest_FlightTrackerAndMemberShip.md | 154 ------- tests/unit/admin.flight.service.test.js | 79 ++-- tests/unit/ancillary.service.test.js | 295 ++++++++++++ tests/unit/flight.service.test.js | 15 +- tests/unit/refund.service.test.js | 436 +++++++----------- tests/unit/wishlist.service.test.js | 232 ++++++++++ 9 files changed, 781 insertions(+), 498 deletions(-) delete mode 100644 tests/unit/HuongDanChayTest_FlightTrackerAndMemberShip.md create mode 100644 tests/unit/ancillary.service.test.js create mode 100644 tests/unit/wishlist.service.test.js 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/src/services/admin/flight.service.js b/src/services/admin/flight.service.js index b19a304..b31280c 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) => { @@ -436,10 +444,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", }; @@ -492,10 +500,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", }; @@ -509,18 +517,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) { @@ -763,7 +771,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 = []; @@ -785,7 +801,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); } @@ -851,7 +869,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/flight.service.js b/src/services/flight.service.js index 1c80beb..b905bd8 100644 --- a/src/services/flight.service.js +++ b/src/services/flight.service.js @@ -1,6 +1,7 @@ // src/services/flight.service.js const pool = require('../config/db'); const QF = require('../queries/flight.queries'); +const priceRuleService = require('./price-rule.service'); /** * Lưu lịch sử tìm kiếm (fire-and-forget, không block search) 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 877e421..acdc60a 100644 --- a/tests/unit/admin.flight.service.test.js +++ b/tests/unit/admin.flight.service.test.js @@ -2,7 +2,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); -const servicePath = path.resolve(__dirname, "../../src/services/admin.flight.service.js"); +const servicePath = path.resolve(__dirname, "../../src/services/admin/flight.service.js"); const dbPath = path.resolve(__dirname, "../../src/config/db.js"); function loadAdminFlightService(poolMock) { @@ -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/refund.service.test.js b/tests/unit/refund.service.test.js index 23fd3ed..886d556 100644 --- a/tests/unit/refund.service.test.js +++ b/tests/unit/refund.service.test.js @@ -1,272 +1,184 @@ -'use strict'; - -/* -========================================================= -UNIT TESTS: REFUND SERVICE -========================================================= -*/ - -const { describe, it, expect, jest, beforeEach } = require('@jest/globals'); - -// Mock dependencies -jest.mock('../../src/config/db', () => ({ - query: jest.fn(), - connect: jest.fn(), -})); - -jest.mock('../../src/config/refund.config', () => ({ - POLICIES: [ - { name: 'full_refund', hoursBefore: 72, refundPercent: 100, label: 'Hoàn 100%' }, - { name: 'high_refund', hoursBefore: 24, refundPercent: 80, label: 'Hoàn 80%' }, - { name: 'medium_refund', hoursBefore: 12, refundPercent: 50, label: 'Hoàn 50%' }, - { name: 'low_refund', hoursBefore: 0, refundPercent: 0, label: 'Không hoàn' }, - ], - ADMIN_FEE: { enabled: false }, - VOUCHER_HANDLING: { refundOnFinalAmount: true }, - VALIDATION: { requireReason: true, minReasonLength: 10, minRefundAmount: 0 }, - CONCURRENCY: { preventDuplicateRequests: true }, - findPolicy: (hours) => { - const policies = [ - { name: 'full_refund', hoursBefore: 72, refundPercent: 100 }, - { name: 'high_refund', hoursBefore: 24, refundPercent: 80 }, - { name: 'medium_refund', hoursBefore: 12, refundPercent: 50 }, - { name: 'low_refund', hoursBefore: 0, refundPercent: 0 }, - ]; - for (const p of policies) { - if (hours >= p.hoursBefore) return p; - } - return policies[policies.length - 1]; - }, -})); - -const pool = require('../../src/config/db'); - -// Import service after mocks -const refundService = require('../../src/services/refund.service'); - -describe('Refund Service', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('calculateRefundAmount', () => { - it('should calculate full refund (100%) when > 72 hours before departure', () => { - const booking = { total_adults: 1, total_children: 0, total_infants: 0 }; - const payment = { amount: 1000000, final_amount: 1000000, discount_amount: 0 }; - const policy = { name: 'full_refund', refundPercent: 100, label: 'Hoàn 100%' }; - - const result = refundService.calculateRefundAmount(booking, payment, policy); - - expect(result.refund_percent).toBe(100); - expect(result.refund_amount).toBe(1000000); - expect(result.net_refund_amount).toBe(1000000); - }); - - it('should calculate 80% refund when 24-72 hours before departure', () => { - const booking = { total_adults: 1, total_children: 0, total_infants: 0 }; - const payment = { amount: 1000000, final_amount: 1000000, discount_amount: 0 }; - const policy = { name: 'high_refund', refundPercent: 80, label: 'Hoàn 80%' }; - - const result = refundService.calculateRefundAmount(booking, payment, policy); - - expect(result.refund_percent).toBe(80); - expect(result.refund_amount).toBe(800000); - expect(result.net_refund_amount).toBe(800000); - }); - - it('should calculate 50% refund when 12-24 hours before departure', () => { - const booking = { total_adults: 1, total_children: 0, total_infants: 0 }; - const payment = { amount: 1000000, final_amount: 1000000, discount_amount: 0 }; - const policy = { name: 'medium_refund', refundPercent: 50, label: 'Hoàn 50%' }; - - const result = refundService.calculateRefundAmount(booking, payment, policy); - - expect(result.refund_percent).toBe(50); - expect(result.refund_amount).toBe(500000); - expect(result.net_refund_amount).toBe(500000); - }); - - it('should calculate 0% refund when < 12 hours before departure', () => { - const booking = { total_adults: 1, total_children: 0, total_infants: 0 }; - const payment = { amount: 1000000, final_amount: 1000000, discount_amount: 0 }; - const policy = { name: 'low_refund', refundPercent: 0, label: 'Không hoàn' }; - - const result = refundService.calculateRefundAmount(booking, payment, policy); - - expect(result.refund_percent).toBe(0); - expect(result.refund_amount).toBe(0); - expect(result.net_refund_amount).toBe(0); - }); - - it('should calculate partial leg refund correctly', () => { - const booking = { total_adults: 2, total_children: 0, total_infants: 0 }; - const payment = { amount: 2000000, final_amount: 2000000, discount_amount: 0 }; - const policy = { name: 'full_refund', refundPercent: 100, label: 'Hoàn 100%' }; - const requestedItems = { legs: ['outbound'] }; - - const result = refundService.calculateRefundAmount(booking, payment, policy, 'partial_leg', requestedItems); - - // 50% cho 1 leg trong round trip - expect(result.refund_percent).toBe(100); - expect(result.refund_amount).toBe(1000000); // 50% của 2000000 - }); - - it('should calculate partial passenger refund correctly', () => { - const booking = { total_adults: 2, total_children: 1, total_infants: 0 }; - const payment = { amount: 3000000, final_amount: 3000000, discount_amount: 0 }; - const policy = { name: 'full_refund', refundPercent: 100, label: 'Hoàn 100%' }; - const requestedItems = { passenger_ids: [1] }; // 1 trong 3 passengers - - const result = refundService.calculateRefundAmount(booking, payment, policy, 'partial_passenger', requestedItems); - - // 1/3 của 3000000 - expect(result.refund_amount).toBe(1000000); - }); - - it('should handle voucher discount correctly', () => { - const booking = { total_adults: 1, total_children: 0, total_infants: 0 }; - const payment = { amount: 1000000, final_amount: 900000, discount_amount: 100000 }; - const policy = { name: 'full_refund', refundPercent: 100, label: 'Hoàn 100%' }; - - const result = refundService.calculateRefundAmount(booking, payment, policy); - - // Refund trên final_amount (900000) không phải amount (1000000) - expect(result.original_amount).toBe(1000000); - expect(result.discount_amount).toBe(100000); - expect(result.base_amount).toBe(900000); - expect(result.refund_amount).toBe(900000); - }); - }); - - describe('calculateHoursUntilDeparture', () => { - it('should calculate hours correctly for future date', () => { - const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now - const hours = refundService.calculateHoursUntilDeparture(futureDate.toISOString()); - - expect(hours).toBeGreaterThan(23); - expect(hours).toBeLessThan(25); - }); - - it('should return 0 for past date', () => { - const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago - const hours = refundService.calculateHoursUntilDeparture(pastDate.toISOString()); +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +const servicePath = path.resolve(__dirname, "../../src/services/refund.service.js"); +const dbPath = path.resolve(__dirname, "../../src/config/db.js"); +const qrPath = path.resolve(__dirname, "../../src/queries/refund.queries.js"); +const qbPath = path.resolve(__dirname, "../../src/queries/booking.queries.js"); +const qpPath = path.resolve(__dirname, "../../src/queries/payment.queries.js"); +const refundConfigPath = path.resolve(__dirname, "../../src/config/refund.config.js"); +const loyaltyPath = path.resolve(__dirname, "../../src/services/loyalty.service.js"); +const notifPath = path.resolve(__dirname, "../../src/services/notification.service.js"); +const mailerPath = path.resolve(__dirname, "../../src/utils/mailer.js"); +const paypalPath = path.resolve(__dirname, "../../src/providers/paypal.provider.js"); +const payosPath = path.resolve(__dirname, "../../src/providers/payos.provider.js"); +const paymentConfigPath = path.resolve(__dirname, "../../src/config/payment.config.js"); + +function loadRefundService(clientQueryImpl) { + delete require.cache[servicePath]; + delete require.cache[dbPath]; + delete require.cache[qrPath]; + delete require.cache[qbPath]; + delete require.cache[qpPath]; + delete require.cache[refundConfigPath]; + delete require.cache[loyaltyPath]; + delete require.cache[notifPath]; + delete require.cache[mailerPath]; + delete require.cache[paypalPath]; + delete require.cache[payosPath]; + delete require.cache[paymentConfigPath]; + + const mockClient = { + query: clientQueryImpl, + release: () => {}, + }; + + require.cache[dbPath] = { + id: dbPath, + filename: dbPath, + loaded: true, + exports: { + connect: async () => mockClient, + query: async () => ({ rows: [] }), + }, + }; + + require.cache[qrPath] = { + id: qrPath, + filename: qrPath, + loaded: true, + exports: { + CHECK_PENDING_REFUND_FOR_BOOKING: "CHECK_PENDING_REFUND_FOR_BOOKING", + CHECK_REFUND_EXISTS_BY_CODE: "CHECK_REFUND_EXISTS_BY_CODE", + INSERT_REFUND: "INSERT_REFUND", + }, + }; + + require.cache[qbPath] = { + id: qbPath, + filename: qbPath, + loaded: true, + exports: { + SELECT_BOOKING_DETAIL: "SELECT_BOOKING_DETAIL", + UPDATE_BOOKING_STATUS: "UPDATE_BOOKING_STATUS", + }, + }; + + require.cache[qpPath] = { + id: qpPath, + filename: qpPath, + loaded: true, + exports: { + SELECT_PAYMENT_BY_BOOKING: "SELECT_PAYMENT_BY_BOOKING", + }, + }; + + require.cache[refundConfigPath] = { + id: refundConfigPath, + filename: refundConfigPath, + loaded: true, + exports: { + POLICIES: [{ name: "early", label: "Som", hoursBefore: 24, refundPercent: 90 }], + ADMIN_FEE: { enabled: true, percent: 5, minAmount: 50000, maxAmount: 500000, exemptStatuses: [] }, + VOUCHER_HANDLING: { refundOnFinalAmount: true }, + VALIDATION: { requireReason: true, minReasonLength: 10, minRefundAmount: 10000 }, + CONCURRENCY: { preventDuplicateRequests: true }, + OTP_CONFIG: { enabled: true, threshold: 5000000, codeLength: 6, expiresInMinutes: 10, maxAttempts: 5, resendCooldownMinutes: 1 }, + findPolicy: () => ({ name: "early", label: "Som", hoursBefore: 24, refundPercent: 90 }), + hoursBeforeDeparture: () => 48, + }, + }; + + require.cache[loyaltyPath] = { id: loyaltyPath, filename: loyaltyPath, loaded: true, exports: { revokePointsForRefund: async () => true } }; + require.cache[notifPath] = { id: notifPath, filename: notifPath, loaded: true, exports: { createRefundNotification: async () => true } }; + require.cache[mailerPath] = { id: mailerPath, filename: mailerPath, loaded: true, exports: { sendRefundOTPEmail: async () => true } }; + require.cache[paypalPath] = { id: paypalPath, filename: paypalPath, loaded: true, exports: { refundPayPalCapture: async () => ({ id: "refund" }) } }; + require.cache[payosPath] = { id: payosPath, filename: payosPath, loaded: true, exports: { getPayosClient: () => ({}) } }; + require.cache[paymentConfigPath] = { id: paymentConfigPath, filename: paymentConfigPath, loaded: true, exports: { paypal: { currency: "VND" }, payos: { enabled: false } } }; + + return require(servicePath); +} + +function makeBooking(overrides = {}) { + const future = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(); + return { + id: 1, + user_id: 42, + status: "confirmed", + trip_type: "one_way", + outbound_departure_time: future, + contact_email: "user@test.com", + ...overrides, + }; +} + +function makePayment(overrides = {}) { + return { + id: 10, + booking_id: 1, + status: "SUCCESS", + amount: "2000000", + final_amount: "2000000", + discount_amount: "0", + ...overrides, + }; +} + +test("requestRefund: báo lỗi khi reason quá ngắn", async () => { + const svc = loadRefundService(async () => ({ rows: [] })); + await assert.rejects( + () => svc.requestRefund(42, "BK001", { refund_type: "full", reason: "ngan" }), + /Lý do yêu cầu refund phải có ít nhất/ + ); +}); - expect(hours).toBe(0); - }); +test("requestRefund: báo lỗi khi booking không tồn tại", async () => { + let call = 0; + const svc = loadRefundService(async () => { + call += 1; + if (call === 1) return { rows: [] }; // BEGIN + if (call === 2) return { rows: [] }; // SELECT_BOOKING_DETAIL + return { rows: [] }; }); - describe('validateRefundRequest', () => { - it('should throw error if booking not found', () => { - expect(() => { - refundService.validateRefundRequest(null, {}, 1); - }).toThrow('Không tìm thấy booking'); - }); - - it('should throw error if booking status is not confirmed', () => { - const booking = { status: 'pending' }; - expect(() => { - refundService.validateRefundRequest(booking, {}, 1); - }).toThrow('Không thể refund booking có trạng thái "pending"'); - }); - - it('should throw error if flight has departed', () => { - const pastDate = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1 hour ago - const booking = { - status: 'confirmed', - outbound_departure_time: pastDate, - trip_type: 'one_way' - }; - - expect(() => { - refundService.validateRefundRequest(booking, {}, 1); - }).toThrow('Không thể refund: Chuyến bay đã khởi hành'); - }); - - it('should throw error if round trip return flight has departed', () => { - const now = new Date(); - const outboundPast = new Date(now - 60 * 60 * 1000).toISOString(); // 1 hour ago - const returnFuture = new Date(now + 48 * 60 * 60 * 1000).toISOString(); // 48 hours from now - - const booking = { - status: 'confirmed', - outbound_departure_time: outboundPast, - return_departure_time: returnFuture, - trip_type: 'round_trip' - }; - - expect(() => { - refundService.validateRefundRequest(booking, {}, 1); - }).toThrow('Không thể refund: Chuyến bay đã khởi hành'); - }); - - it('should throw error if payment not found', () => { - const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); - const booking = { - status: 'confirmed', - outbound_departure_time: futureDate, - trip_type: 'one_way' - }; - - expect(() => { - refundService.validateRefundRequest(booking, null, 1); - }).toThrow('Không tìm thấy thông tin thanh toán'); - }); - - it('should throw error if payment status is not SUCCESS', () => { - const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); - const booking = { - status: 'confirmed', - outbound_departure_time: futureDate, - trip_type: 'one_way' - }; - const payment = { status: 'PENDING' }; - - expect(() => { - refundService.validateRefundRequest(booking, payment, 1); - }).toThrow('Chỉ booking đã thanh toán thành công mới được refund'); - }); - }); + await assert.rejects( + () => svc.requestRefund(42, "BK-NOT-FOUND", { refund_type: "full", reason: "Ly do nay du dai hon 10 ky tu" }), + /Không tìm thấy booking/ + ); }); -describe('Refund Config', () => { - const { findPolicy, POLICIES } = require('../../src/config/refund.config'); - - describe('findPolicy', () => { - it('should return full_refund for > 72 hours', () => { - const policy = findPolicy(100); - expect(policy.name).toBe('full_refund'); - expect(policy.refundPercent).toBe(100); - }); - - it('should return high_refund for 24-72 hours', () => { - const policy = findPolicy(48); - expect(policy.name).toBe('high_refund'); - expect(policy.refundPercent).toBe(80); - }); - - it('should return medium_refund for 12-24 hours', () => { - const policy = findPolicy(18); - expect(policy.name).toBe('medium_refund'); - expect(policy.refundPercent).toBe(50); - }); +test("requestGuestRefund: báo lỗi email xác thực không hợp lệ", async () => { + const svc = loadRefundService(async () => ({ rows: [] })); + await assert.rejects( + () => svc.requestGuestRefund("BK001", "invalid-email", { refund_type: "full", reason: "Ly do nay du dai hon 10 ky tu" }), + /Email xác thực không hợp lệ/ + ); +}); - it('should return low_refund for < 12 hours', () => { - const policy = findPolicy(6); - expect(policy.name).toBe('low_refund'); - expect(policy.refundPercent).toBe(0); - }); +test("requestRefund: tạo refund thành công với dữ liệu hợp lệ", async () => { + let call = 0; + const refundRow = { refund_code: "REF-20260101-ABC123", status: "pending" }; + + const svc = loadRefundService(async (sql) => { + call += 1; + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") return { rows: [] }; + if (sql === "SELECT_BOOKING_DETAIL") return { rows: [makeBooking()] }; + if (sql === "SELECT_PAYMENT_BY_BOOKING") return { rows: [makePayment()] }; + if (sql === "CHECK_PENDING_REFUND_FOR_BOOKING") return { rows: [] }; + if (sql === "CHECK_REFUND_EXISTS_BY_CODE") return { rows: [] }; + if (sql === "INSERT_REFUND") return { rows: [refundRow] }; + if (sql === "UPDATE_BOOKING_STATUS") return { rows: [] }; + return { rows: [] }; }); - describe('POLICIES', () => { - it('should have 4 policies defined', () => { - expect(POLICIES).toHaveLength(4); - }); - - it('should have correct refund percentages', () => { - expect(POLICIES[0].refundPercent).toBe(100); - expect(POLICIES[1].refundPercent).toBe(80); - expect(POLICIES[2].refundPercent).toBe(50); - expect(POLICIES[3].refundPercent).toBe(0); - }); + const result = await svc.requestRefund(42, "BK001", { + refund_type: "full", + reason: "Ly do nay du dai hon 10 ky tu", }); + + assert.equal(result.success, true); + assert.equal(result.refund_code, refundRow.refund_code); + assert.equal(result.status, "pending"); + assert.ok(call > 0); }); 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); +}); From 51b4f888ba1c0f3a747dfdfddc3bfb1550fd4bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Fri, 29 May 2026 16:32:36 +0700 Subject: [PATCH 09/11] Flight notifications DONE --- src/config/refund.config.js | 7 + src/services/flight.service.js | 83 ++++++- src/services/notification.service.js | 318 ++++++++++++++++++++------- 3 files changed, 331 insertions(+), 77 deletions(-) diff --git a/src/config/refund.config.js b/src/config/refund.config.js index 12d5c08..1ed127f 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/services/flight.service.js b/src/services/flight.service.js index 1d28104..aa5a02f 100644 --- a/src/services/flight.service.js +++ b/src/services/flight.service.js @@ -2,7 +2,7 @@ const pool = require('../config/db'); const QF = require('../queries/flight.queries'); const priceRuleService = require('./price-rule.service'); - +const notificationService = require('./notification.service'); /** * Lưu lịch sử tìm kiếm (fire-and-forget, không block search) */ @@ -755,6 +755,85 @@ const getPriceCalendar = async (params = {}) => { return result.rows; }; +/** + * Cập nhật thông tin chuyến bay + Gửi thông báo Flight Notification + */ +const updateFlight = async (flightId, updateData) => { + // Cập nhật dữ liệu chuyến bay + const result = await pool.query(` + UPDATE flights + SET ${Object.keys(updateData).map((key, i) => `${key} = $${i+1}`).join(', ')} + WHERE id = $${Object.keys(updateData).length + 1} + RETURNING * + `, [...Object.values(updateData), flightId]); + + if (result.rows.length === 0) { + throw new Error("Không tìm thấy chuyến bay"); + } + + const updatedFlight = result.rows[0]; + + // === GỬI THÔNG BÁO FLIGHT NOTIFICATION === + if (updateData.status || updateData.departure_time || updateData.gate || updateData.baggage_info) { + + // Lấy danh sách booking bị ảnh hưởng + const bookingsResult = await pool.query(` + SELECT b.id, b.booking_code, b.email, b.passenger_name + FROM bookings b + WHERE b.outbound_flight_id = $1 + AND b.status IN ('confirmed', 'pending') + `, [flightId]); + + let eventType = 'FLIGHT_STATUS_CHANGED'; + + if (updateData.status === 'delayed' || updateData.status === 'cancelled') { + eventType = 'FLIGHT_DELAYED'; + } else if (updateData.gate) { + eventType = 'FLIGHT_GATE_CHANGED'; + } else if (updateData.departure_time) { + eventType = 'FLIGHT_TIME_CHANGED'; + } else if (updateData.baggage_info) { + eventType = 'FLIGHT_BAGGAGE_CHANGED'; + } + + await notificationService.createFlightNotification({ + event: eventType, + flight: updatedFlight, + bookings: bookingsResult.rows, + changes: { + reason: updateData.reason || 'Cập nhật từ hệ thống', + new_departure_time: updateData.departure_time, + new_gate: updateData.gate, + old_gate: updateData.old_gate, + baggage_info: updateData.baggage_info + } + }); + } + + return updatedFlight; +}; + +/** + * Lấy danh sách booking của một chuyến bay (dùng cho Flight Notification) + * Chỉ lấy những booking còn hiệu lực (confirmed hoặc pending) + */ +const getBookingsByFlight = async (flightId) => { + const { rows } = await pool.query(` + SELECT + b.id, + b.booking_code, + b.email, + b.passenger_name, + b.status + FROM bookings b + WHERE b.outbound_flight_id = $1 + AND b.status IN ('confirmed', 'pending') + ORDER BY b.created_at ASC + `, [flightId]); + + return rows; +}; + module.exports = { recommendFlights, saveSearchHistory, @@ -766,4 +845,6 @@ module.exports = { getPriceCalendar, getSeatMap, getFlightPosition, + updateFlight, + getBookingsByFlight, }; \ 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, }; From eea25188479e223793e5769b1afa917650a6e772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Fri, 29 May 2026 17:01:07 +0700 Subject: [PATCH 10/11] =?UTF-8?q?ch=E1=BB=A9c=20n=C4=83ng=20h=E1=BB=A7y=20?= =?UTF-8?q?v=C3=A9=20v=C3=A0=20chuy=E1=BA=BFn=20bay=20=C4=91=C3=A3=20bay?= =?UTF-8?q?=20m=C3=A0=20user=20ch=C6=B0a=20thanh=20to=C3=A1n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update done --- src/services/booking.service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/booking.service.js b/src/services/booking.service.js index e5cb44b..f2c1baf 100644 --- a/src/services/booking.service.js +++ b/src/services/booking.service.js @@ -573,12 +573,12 @@ const autoCompleteFlights = async () => { await client.query("BEGIN"); // 1. Chuyển tất cả chuyến bay đã khởi hành sang "completed" - // Buffer 3 tiếng — khớp với cấp 3 ở search query const completedResult = await client.query(` UPDATE flights SET status = 'completed', updated_at = NOW() - WHERE departure_time < NOW() - INTERVAL '3 hours' + 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 `); From 597c8b458caae5bd375bb0a246efcfe8c148fbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Nghi=E1=BB=87p=20Tu=E1=BA=A5n?= Date: Fri, 29 May 2026 17:30:34 +0700 Subject: [PATCH 11/11] Update flight.service.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gợi ý chuyến bay đã fix cho nó không lấy những chuyến đã cất cánh --- src/services/flight.service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/flight.service.js b/src/services/flight.service.js index aa5a02f..ac10834 100644 --- a/src/services/flight.service.js +++ b/src/services/flight.service.js @@ -202,8 +202,7 @@ const recommendFlights = async ({ userId, sessionId, fromAirport, toAirport, lim WHERE f.status = 'scheduled' AND f.is_active = TRUE AND fs.available_seats > 0 - AND f.departure_time BETWEEN NOW() - INTERVAL '3 hours' - AND NOW() + INTERVAL '10 days' + AND f.departure_time > NOW() AND f.departure_time < NOW() + INTERVAL '60 days' ${fromAirport ? `AND dep.code = '${fromAirport}'` : ''} ${toAirport ? `AND arr.code = '${toAirport}'` : ''} @@ -407,6 +406,7 @@ const queryFlights = async ({ conditions.push(`fs.available_seats >= $${idx++}`); values.push(seatsNeeded); conditions.push(`f.status = 'scheduled'`); conditions.push(`f.is_active = TRUE`); + conditions.push(`f.departure_time > NOW()`); if (min_price !== undefined && min_price !== "") { conditions.push(`fs.base_price >= $${idx++}`); values.push(parseFloat(min_price)); } if (max_price !== undefined && max_price !== "") { conditions.push(`fs.base_price <= $${idx++}`); values.push(parseFloat(max_price)); }