diff --git a/.env.example b/.env.example index 26d6b3d..667d26f 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,40 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres # Cổng Postgres publish ra máy host; đổi nếu máy đã có Postgres chiếm 5432 DB_PORT=5432 + +# --- Tài khoản quản trị đầu tiên --- +# Tạo lúc khởi động nếu email chưa có trong DB. Bỏ trống -> không tạo. +# Chỉ tạo mới, không ghi đè tài khoản đã tồn tại. +ADMIN_EMAIL=admin@tripgo.local +ADMIN_PASSWORD= +ADMIN_NAME="Quản trị viên" + +# --- Đăng nhập Facebook (tuỳ chọn) --- +# Lấy ở https://developers.facebook.com/apps -> App settings -> Basic. +# Bỏ trống -> tính năng không bật, app vẫn chạy với đăng nhập email/mật khẩu. +# Trong app Facebook, thêm Valid OAuth Redirect URI: +# http://localhost:8080/login/oauth2/code/facebook +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= +# Có giá trị -> sau khi đăng nhập xong redirect về đây kèm ?token=. +# Bỏ trống -> callback trả thẳng JSON { token, user }. +OAUTH2_SUCCESS_REDIRECT_URI= + +# --- Job nền --- +# false để tắt hẳn job dọn dẹp định kỳ. +TASKS_ENABLED=true +# Đơn chờ xác nhận quá số giờ này sẽ tự huỷ và hoàn chỗ. +BOOKING_EXPIRE_HOURS=72 +# Bao lâu job kiểm tra đơn quá hạn / tính lại điểm đánh giá một lần. +# Định dạng thời lượng: PT2M = 2 phút, PT30S = 30 giây, PT1H = 1 giờ. Demo thì đặt PT2M cho nhanh. +BOOKING_EXPIRE_CHECK_INTERVAL=PT1H +RATING_REFRESH_INTERVAL=PT6H + +# --- Hàng đợi gửi mail (tuỳ chọn) --- +# false: gửi thẳng trên pool nền, không cần broker. true: qua hàng đợi ActiveMQ. +JMS_ENABLED=false +MQ_USER=admin +MQ_PASSWORD=admin +# Cổng ActiveMQ publish ra máy host; đổi nếu đã có broker khác chiếm chỗ. +MQ_PORT=61616 +MQ_CONSOLE_PORT=8161 diff --git a/.gitignore b/.gitignore index 334ddcf..b6e214f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ REQUIREMENTS.md # nên !docs/api.http sẽ không có tác dụng nếu ignore cả thư mục. docs/* !docs/api.http + +# Ảnh admin tải lên lúc chạy; không thuộc mã nguồn. +uploads/ diff --git a/README.md b/README.md index 55a7b1b..7496b73 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ REST API đặt tour du lịch: tìm kiếm/lọc tour, xem chi tiết & ngày k đánh giá tour. Xây bằng **Spring Boot 4.1 / Java 21 / PostgreSQL**, xác thực **JWT**. - Base URL: `http://localhost:8080/api/v1` -- Swagger UI: `http://localhost:8080/api/v1/swagger-ui.html` (tắt ở profile `prod`) +- Swagger UI: `http://localhost:8080/swagger-ui.html` (tắt ở profile `prod`) +- Ứng dụng không dùng `context-path`: tiền tố `/api/v1` do `ApiPathConfig` gắn riêng cho + package `demo.tripgo.controller`, để khu quản trị `/admin/**` nằm ngoài tiền tố đó. - Bộ request mẫu: [`docs/api.http`](docs/api.http) --- @@ -64,6 +66,174 @@ Message trả về bằng tiếng Việt. `code` dùng tên `HttpStatus`, trừ 401 chưa xác thực · 403 thiếu quyền · 404 không tồn tại **hoặc không sở hữu** · 409 xung đột (trùng email/đánh giá, hết chỗ, huỷ đơn đã huỷ) · 422 lỗi validate · 429 vượt rate limit. +## 2b. Khu quản trị (giao diện) + +Trang quản trị render phía server bằng Thymeleaf, tách hẳn khỏi API: + +| | API `/api/v1/**` | Quản trị `/admin/**` | +|---|---|---| +| Xác thực | Bearer JWT | form đăng nhập + session | +| CSRF | tắt (không dùng cookie) | **bật** | +| Quyền | theo từng endpoint | `hasRole('ADMIN')` | + +Hai `SecurityFilterChain` riêng biệt (`SecurityConfig` và `AdminSecurityConfig`), phân biệt bằng +`securityMatcher`, nên thay đổi ở khu quản trị không ảnh hưởng API. + +| Đường dẫn | Mô tả | +|---|---| +| `/admin/login` | Đăng nhập (công khai) | +| `/admin` | Dashboard | +| `/admin/logout` | Đăng xuất (POST, có token CSRF) | + +**Tạo tài khoản admin đầu tiên:** đặt `ADMIN_EMAIL` và `ADMIN_PASSWORD` trong `.env` rồi khởi động +lại. Tài khoản chỉ được **tạo mới**, không bao giờ ghi đè tài khoản đã tồn tại — đổi biến môi +trường sẽ không đổi mật khẩu admin đang dùng. + +Giao diện dùng Bootstrap 5 (CDN) + `src/main/resources/static/css/admin.css`. + +--- + +## 2c. Đăng nhập Facebook (tuỳ chọn) + +Bỏ trống `FACEBOOK_CLIENT_ID` thì tính năng không được bật và ứng dụng chạy bình thường với +đăng nhập email/mật khẩu. + +| Đường dẫn | Mô tả | +|---|---| +| `/oauth2/authorization/facebook` | Bắt đầu đăng nhập (mở bằng trình duyệt) | +| `/login/oauth2/code/facebook` | Callback Facebook gọi về | + +Khai trong app Facebook (App settings → Basic) và thêm **Valid OAuth Redirect URI**: +`http://localhost:8080/login/oauth2/code/facebook` + +> **Database đã có dữ liệu thì phải chạy migration trước:** +> ```bash +> psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/migrate-social-login.sql +> ``` +> `ddl-auto=update` không gỡ được ràng buộc `NOT NULL` khỏi cột `password` (tài khoản Facebook +> không có mật khẩu). Bỏ qua bước này thì tạo tài khoản Facebook sẽ lỗi ở tầng DB. + +Xong luồng, TripGo đổi sang **JWT của chính nó** để client dùng chung một loại token cho mọi +endpoint. Đặt `OAUTH2_SUCCESS_REDIRECT_URI` thì callback redirect về đó kèm `?token=`; bỏ trống +thì trả thẳng JSON `{ token, user }` như `POST /auth/login`. + +Tài khoản Facebook không có mật khẩu, nên `POST /auth/login` với email đó trả 401 kèm lời nhắn +dùng đăng nhập Facebook. Nếu email đã có tài khoản email/mật khẩu, Facebook được **gắn vào chính +tài khoản đó** để giữ nguyên wishlist và đơn hàng cũ. + +--- + +## 2d. Xuất Excel + +| Trang | Nút | Nội dung | +|---|---|---| +| `/admin/bookings` | Xuất Excel | Đơn theo **đúng bộ lọc đang xem** | +| `/admin/tours` | Xuất Excel | Tour theo từ khoá đang tìm | + +Tầng Excel (`demo.tripgo.excel`) dùng chung cho mọi loại dữ liệu: gắn `@ExcelColumn` lên field, +`ExcelMapper` đọc bằng reflection rồi tự sinh file. Thêm loại mới không phải viết code đọc/ghi. + +## 2i. SOAP cho hệ thống đối tác + +| | | +|---|---| +| WSDL | `GET /ws/tourAvailability.wsdl` | +| Endpoint | `POST /ws` | +| Thao tác | `getTourAvailability(tourSlug, fromDate?)` → danh sách ngày khởi hành + chỗ trống | + +**Contract-first**: `src/main/resources/soap/tripgo.xsd` là nguồn sự thật duy nhất — Spring-WS sinh +WSDL từ đó, `jaxb2-maven-plugin` sinh lớp Java từ đó, nên hai bên không lệch nhau được. + +Endpoint mở công khai vì client SOAP không có phiên đăng nhập để mang theo, và nó chỉ đọc + chỉ +trả số liệu chỗ trống, không có dữ liệu khách hàng. Tour đã xoá mềm không lộ ra. +Slug sai trả **SOAP Fault dạng CLIENT** để đối tác biết là phải sửa request chứ không phải thử lại. + +## 2h. Hàng đợi gửi mail (JMS) + +Mail xác nhận đặt tour đi qua hàng đợi `tripgo.booking.mail` thay vì gửi ngay trong request. + +| `JMS_ENABLED` | Cách gửi | +|---|---| +| `false` (mặc định) | `DirectBookingMailDispatcher` — gửi thẳng trên pool nền, **không cần broker** | +| `true` | Qua ActiveMQ; listener ném lỗi thì message quay lại hàng đợi để thử lại | + +Mặc định tắt vì có ActiveMQ trên classpath mà không có broker thì listener thử kết nối lại liên +tục và làm log đầy lỗi. Bật thì chạy kèm service `mq` trong `docker-compose.yml`. + +Hai bản cài cùng một interface nên phần còn lại của ứng dụng không cần biết có broker hay không — +và việc gửi mail không lặng lẽ biến mất khi ai đó quên bật JMS. + +## 2g. Thông báo realtime (WebSocket + STOMP) + +Admin đang mở bất kỳ trang quản trị nào sẽ thấy thẻ thông báo ở góc màn hình khi có đơn mới, +đơn được xác nhận hoặc bị huỷ. + +- Endpoint: `/admin/ws` — nằm **trong** khu quản trị nên chain bảo mật của admin bảo vệ luôn; + đặt ở ngoài thì phải mở công khai và ai cũng nghe được thông tin đơn hàng của khách. +- Kênh: `/topic/bookings`, broker trong bộ nhớ. +- **Không dùng SockJS**: SockJS dự phòng bằng transport POST, sẽ vướng CSRF của chain quản trị. + WebSocket thuần chỉ cần một GET Upgrade. + +Sự kiện phát ở `BookingService`/`BookingAdminService` và chỉ được gửi đi **sau khi transaction +commit** (`@TransactionalEventListener(AFTER_COMMIT)`) — nếu không, một lỗi làm rollback sẽ để lại +thông báo về đơn không hề tồn tại. + +## 2f. Job nền + +| Job | Nhịp | Việc | +|---|---|---| +| Tự huỷ đơn quá hạn | mỗi giờ | Đơn `PENDING` quá `BOOKING_EXPIRE_HOURS` (mặc định 72h) → huỷ và **hoàn chỗ** | +| Tính lại đánh giá | mỗi 6 giờ | So `rating_avg`/`review_count` với bảng `reviews`, sửa tour bị lệch | + +Tắt bằng `TASKS_ENABLED=false`. Job chạy trên `ThreadPoolTaskExecutor` riêng (`AsyncConfig`) chứ +không dùng executor mặc định — executor mặc định tạo một luồng mới cho mỗi lần gọi, không giới hạn. + +Việc huỷ đơn đi qua đúng `BookingService.cancelById` mà admin dùng, nên phần hoàn chỗ (có khoá +bi quan) chỉ tồn tại ở một nơi. + +## 2e. Biểu đồ dashboard + +Một biểu đồ (Chart.js): doanh thu theo từng ngày đặt đơn của một tháng, chọn tháng bằng ô chọn +(không chọn được tháng tương lai), không tính đơn đã huỷ. +Dữ liệu tổng hợp lấy từ `GET /admin/reports/chart-data?month=yyyy-MM`. + +Biểu đồ cập nhật **realtime**: khi có đơn mới / xác nhận / huỷ, hoặc job huỷ đơn quá hạn chạy xong +và có huỷ đơn, server gửi tín hiệu STOMP tới `/topic/chart-updates` (`ChartUpdatePublisher`). +Trình duyệt nhận tín hiệu rồi gọi lại `/chart-data` cho đúng tháng đang xem và cập nhật Chart.js. +Tín hiệu không mang số liệu vì mỗi admin có thể đang xem một tháng khác nhau. Dùng WebSocket thuần, +không SockJS (xem `WebSocketConfig`). + +Biểu đồ chỉ có **một** chuỗi số liệu nên dùng **một màu** — tô mỗi cột một màu khi chúng cùng +ý nghĩa là gán màu theo thứ hạng chứ không theo dữ liệu. Màu khai trong `admin.css` dưới dạng +custom property (`--viz-*`) để JS đọc lại, nhờ vậy bảng màu chỉ tồn tại ở một chỗ. + +Biểu đồ kèm một bảng số liệu (`Xem dạng bảng`): biểu đồ không được là cách duy nhất đọc được +con số. + +## 2e2. Nhập tour từ Excel + +`/admin/tours/import` — có nút tải **file mẫu** sinh từ chính lớp `TourImportRow`, nên cột trong +file mẫu không bao giờ lệch với cột bộ nhập mong đợi. + +| Cột bắt buộc | Ghi chú | +|---|---| +| `Tên tour` | | +| `Điểm đến` | nhập **slug** (vd `da-nang`) | +| `Loại hình` | nhập **slug** (vd `beach`) | +| `Giá`, `Số ngày`, `Số khách tối đa` | | + +Cột khớp theo **tên tiêu đề** nên thứ tự cột không quan trọng; thừa cột cũng không sao. + +Nguyên tắc xử lý: + +- **Nhập được dòng nào hay dòng đó.** Một ô gõ sai chỉ làm hỏng dòng đó, kèm số dòng đúng như + trong Excel để tìm mà sửa. +- **Slug trùng thì bỏ qua**, không ghi đè — nhập lại cùng một file không tạo bản sao và không + đè lên dữ liệu admin đã sửa tay. +- Mỗi dòng là một transaction riêng, nên một dòng hỏng không kéo đổ những dòng đã lưu. + +--- + ## 3. Chạy bằng Docker (khuyến nghị) Yêu cầu Docker + Docker Compose. Compose dựng sẵn cả Postgres lẫn API. @@ -132,7 +302,7 @@ Mở [`docs/api.http`](docs/api.http) trong IntelliJ IDEA (HTTP Client) hoặc V (extension *REST Client*) rồi bấm **Send Request**. File phủ đủ 17 endpoint kèm các case lỗi (401/403/404/409/422/429); token được gán tự động sau request đăng nhập. -Hoặc dùng Swagger UI tại `/api/v1/swagger-ui.html`. +Hoặc dùng Swagger UI tại `/swagger-ui.html`. ## 7. Kiểm thử diff --git a/docker-compose.yml b/docker-compose.yml index f505b40..c9efd5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,21 @@ services: timeout: 5s retries: 10 + # Hàng đợi gửi mail. Chỉ cần khi JMS_ENABLED=true; để mặc định thì api không đụng tới nó. + mq: + image: apache/activemq-classic:6.2.0 + environment: + ACTIVEMQ_CONNECTION_USER: ${MQ_USER:-admin} + ACTIVEMQ_CONNECTION_PASSWORD: ${MQ_PASSWORD:-admin} + ports: + - "${MQ_PORT:-61616}:61616" + - "${MQ_CONSOLE_PORT:-8161}:8161" + healthcheck: + test: ["CMD-SHELL", "curl -fsS -u $${ACTIVEMQ_CONNECTION_USER}:$${ACTIVEMQ_CONNECTION_PASSWORD} http://localhost:8161/admin/ || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + api: build: . depends_on: @@ -31,6 +46,15 @@ services: JWT_SECRET: ${JWT_SECRET} JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*} + # Bỏ trống -> đăng nhập Facebook không được bật (xem .env.example). + FACEBOOK_CLIENT_ID: ${FACEBOOK_CLIENT_ID:-} + FACEBOOK_CLIENT_SECRET: ${FACEBOOK_CLIENT_SECRET:-} + OAUTH2_SUCCESS_REDIRECT_URI: ${OAUTH2_SUCCESS_REDIRECT_URI:-} + # Bật hàng đợi gửi mail. Để false thì api gửi thẳng trên pool nền, không cần broker. + JMS_ENABLED: ${JMS_ENABLED:-false} + MQ_URL: tcp://mq:61616 + MQ_USER: ${MQ_USER:-admin} + MQ_PASSWORD: ${MQ_PASSWORD:-admin} RATE_LIMIT_AUTH_MAX: ${RATE_LIMIT_AUTH_MAX:-20} RATE_LIMIT_AUTH_WINDOW: ${RATE_LIMIT_AUTH_WINDOW:-60} ports: diff --git a/pom.xml b/pom.xml index 4e18c02..4222c98 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,62 @@ 21 + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web-services + + + wsdl4j + wsdl4j + + + + org.springframework.ws + spring-ws-test + test + + + + + org.springframework.boot + spring-boot-starter-activemq + + + + org.apache.activemq + activemq-broker + test + + + + + org.springframework.boot + spring-boot-starter-websocket + + + + + org.apache.poi + poi-ooxml + 5.5.1 + org.springframework.boot spring-boot-starter-data-jpa @@ -46,6 +101,16 @@ org.springframework.boot spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.springframework.boot + spring-boot-starter-oauth2-client + org.postgresql @@ -97,6 +162,25 @@ spring-boot-starter-webmvc-test test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + org.springframework.boot spring-boot-starter-test @@ -118,6 +202,29 @@ + + + org.codehaus.mojo + jaxb2-maven-plugin + 3.2.0 + + + xjc + + xjc + + + + + + src/main/resources/soap/tripgo.xsd + + ${project.build.directory}/generated-sources/jaxb + false + + org.springframework.boot spring-boot-maven-plugin diff --git a/scripts/migrate-social-login.sql b/scripts/migrate-social-login.sql new file mode 100644 index 0000000..84164de --- /dev/null +++ b/scripts/migrate-social-login.sql @@ -0,0 +1,31 @@ +-- Chuẩn bị bảng users cho đăng nhập Facebook. +-- +-- BẮT BUỘC chạy trên database đã có dữ liệu. Lý do: ddl-auto=update của Hibernate chỉ THÊM được +-- thứ mới, nó không: +-- - thêm được cột NOT NULL vào bảng đã có dòng (Postgres từ chối vì dòng cũ mang NULL), +-- - gỡ được ràng buộc NOT NULL khỏi cột password. +-- Hibernate gặp lỗi DDL chỉ ghi WARN rồi chạy tiếp, nên ứng dụng vẫn khởi động nhưng mọi truy vấn +-- trên users sẽ hỏng — triệu chứng là không đăng nhập được mà log không báo gì rõ ràng. +-- +-- Chạy: psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/migrate-social-login.sql + +BEGIN; + +-- 1) Tài khoản Facebook không có mật khẩu. +ALTER TABLE users ALTER COLUMN password DROP NOT NULL; + +-- 2) Nguồn gốc tài khoản. Thêm cột cho phép NULL trước, điền dữ liệu cũ, rồi mới siết NOT NULL. +ALTER TABLE users ADD COLUMN IF NOT EXISTS provider VARCHAR(20); +ALTER TABLE users ADD COLUMN IF NOT EXISTS provider_id VARCHAR(255); + +UPDATE users SET provider = 'LOCAL' WHERE provider IS NULL; + +-- 3) Một tài khoản Facebook chỉ gắn được vào đúng một user. Postgres coi các NULL là khác nhau +-- nên mọi user LOCAL (provider_id = NULL) không đụng ràng buộc này. +CREATE UNIQUE INDEX IF NOT EXISTS uk_users_provider + ON users (provider, provider_id); + +COMMIT; + +-- Kiểm tra lại +SELECT provider, count(*) FROM users GROUP BY provider; diff --git a/scripts/set-placeholder-images.sql b/scripts/set-placeholder-images.sql new file mode 100644 index 0000000..e5ce62c --- /dev/null +++ b/scripts/set-placeholder-images.sql @@ -0,0 +1,35 @@ +-- Gán ảnh đã tải lên làm ảnh hiển thị, thay cho các link seed chết (https://img/...). +-- +-- Bối cảnh: dữ liệu mẫu ban đầu trỏ tới https://img/.jpg — tên miền không tồn tại, nên +-- mọi ảnh trong ứng dụng đều vỡ. Script này trỏ chúng về file thật trong thư mục uploads. +-- +-- Chạy: psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/set-placeholder-images.sql +-- +-- Yêu cầu: các file tương ứng phải nằm sẵn trong thư mục UPLOAD_DIR (mặc định ./uploads), +-- vì ứng dụng phục vụ chúng qua ResourceHandler ánh xạ /uploads/** -> thư mục đó. + +BEGIN; + +-- 1) Ảnh đại diện của tour (55 dòng) +UPDATE tours + SET thumbnail_url = '/uploads/dulich.jpg' + WHERE deleted_at IS NULL + AND (thumbnail_url IS NULL OR thumbnail_url LIKE 'https://img/%'); + +-- 2) Ảnh điểm đến (8 dòng) +UPDATE destinations + SET image = '/uploads/anh1.png' + WHERE deleted_at IS NULL + AND (image IS NULL OR image LIKE 'https://img/%'); + +-- 3) Ảnh gallery của tour (165 dòng) +UPDATE tour_images + SET url = '/uploads/anh2.webp' + WHERE url LIKE 'https://img/%'; + +COMMIT; + +-- Kiểm tra lại: không còn link https://img/ nào sót +SELECT 'tours' AS bang, count(*) FILTER (WHERE thumbnail_url LIKE 'https://img/%') AS con_link_hong FROM tours +UNION ALL SELECT 'destinations', count(*) FILTER (WHERE image LIKE 'https://img/%') FROM destinations +UNION ALL SELECT 'tour_images', count(*) FILTER (WHERE url LIKE 'https://img/%') FROM tour_images; diff --git a/src/main/java/demo/tripgo/admin/AdminBookingController.java b/src/main/java/demo/tripgo/admin/AdminBookingController.java new file mode 100644 index 0000000..b89d69d --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminBookingController.java @@ -0,0 +1,176 @@ +package demo.tripgo.admin; + +import demo.tripgo.dto.response.BookingResponse; +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Tour; +import demo.tripgo.exception.BookingAlreadyCancelledException; +import demo.tripgo.exception.InvalidBookingRequestException; +import demo.tripgo.exception.ResourceNotFoundException; +import demo.tripgo.exception.SoldOutException; +import demo.tripgo.service.BookingAdminService; +import demo.tripgo.service.ExcelExportService; +import jakarta.validation.Valid; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.List; + +@Controller +@RequestMapping("/admin/bookings") +public class AdminBookingController { + + private static final int PAGE_SIZE = 20; + + private final BookingAdminService bookingAdminService; + private final ExcelExportService excelExportService; + + public AdminBookingController( + BookingAdminService bookingAdminService, + ExcelExportService excelExportService + ) { + this.bookingAdminService = bookingAdminService; + this.excelExportService = excelExportService; + } + + @GetMapping + public String list( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + Model model + ) { + BookingStatus filter = parseStatus(status); + Page bookings = + bookingAdminService.list(filter, Math.max(page, 1), PAGE_SIZE); + + model.addAttribute("activeMenu", "bookings"); + model.addAttribute("pageHeading", "Đơn đặt"); + model.addAttribute("bookings", bookings.getContent()); + model.addAttribute("statuses", BookingStatus.values()); + model.addAttribute("selectedStatus", filter); + model.addAttribute("currentPage", bookings.getNumber() + 1); + model.addAttribute("totalPages", Math.max(bookings.getTotalPages(), 1)); + model.addAttribute("total", bookings.getTotalElements()); + return "admin/bookings/list"; + } + + // ---- Admin tạo đơn hộ khách ---- + + // Chọn tour trước (GET ?tourId=) rồi mới hiện các ngày khởi hành của tour đó. Tải lại trang + // thay vì gọi AJAX: không phụ thuộc JavaScript, và URL mang theo tour đang chọn. + @GetMapping("/new") + public String createForm(@RequestParam(required = false) Long tourId, Model model) { + AdminBookingForm form = new AdminBookingForm(); + form.setTourId(tourId); + prepareForm(model, form); + return "admin/bookings/form"; + } + + @PostMapping + public String create( + @Valid @ModelAttribute("form") AdminBookingForm form, + BindingResult binding, + Model model, + RedirectAttributes redirect + ) { + if (binding.hasErrors()) { + prepareForm(model, form); + return "admin/bookings/form"; + } + try { + BookingResponse booking = bookingAdminService.createForCustomer(form); + redirect.addFlashAttribute("flashMessage", + "Đã tạo đơn " + booking.code() + " cho " + form.getCustomerEmail().trim() + ", đang chờ xác nhận"); + redirect.addFlashAttribute("flashType", "success"); + return "redirect:/admin/bookings"; + } catch (InvalidBookingRequestException | SoldOutException | ResourceNotFoundException exception) { + // Hết chỗ, khách không tồn tại, tour vừa bị xoá... là tình huống nghiệp vụ bình thường: + // báo lại ngay trên form, giữ nguyên dữ liệu đã nhập. + binding.reject("booking.invalid", exception.getMessage()); + prepareForm(model, form); + return "admin/bookings/form"; + } + } + + private void prepareForm(Model model, AdminBookingForm form) { + model.addAttribute("form", form); + model.addAttribute("activeMenu", "bookings"); + model.addAttribute("pageHeading", "Tạo đơn đặt tour"); + List tours = bookingAdminService.bookableTours(); + model.addAttribute("tours", tours); + // null khi chưa chọn hoặc tourId không còn bán (vào thùng rác, sửa tay URL). + model.addAttribute("selectedTour", tours.stream() + .filter(tour -> tour.getId().equals(form.getTourId())) + .findFirst() + .orElse(null)); + model.addAttribute("departures", bookingAdminService.upcomingDepartures(form.getTourId())); + model.addAttribute("customers", bookingAdminService.bookableCustomers()); + } + + // Xuất đúng bộ lọc đang xem, nên URL nhận cùng tham số status với trang danh sách. + @GetMapping("/export") + public ResponseEntity export(@RequestParam(required = false) String status) { + BookingStatus filter = parseStatus(status); + String suffix = filter == null ? "tat-ca" : filter.name().toLowerCase(java.util.Locale.ROOT); + return ExcelDownload.of(excelExportService.exportBookings(filter), "don-dat-" + suffix); + } + + @PostMapping("/{id}/confirm") + public String confirm(@PathVariable Long id, @RequestParam(required = false) String status, + RedirectAttributes redirect) { + return applyChange(redirect, status, + () -> bookingAdminService.confirm(id), "Đã xác nhận đơn %s"); + } + + @PostMapping("/{id}/cancel") + public String cancel(@PathVariable Long id, @RequestParam(required = false) String status, + RedirectAttributes redirect) { + return applyChange(redirect, status, + () -> bookingAdminService.cancel(id), "Đã huỷ đơn %s, số chỗ đã được hoàn lại"); + } + + // Đổi trạng thái hỏng (đơn không còn chờ xác nhận) không phải lỗi hệ thống mà là tình huống + // bình thường khi hai admin thao tác cùng lúc -> báo lại trên chính trang danh sách. + private String applyChange( + RedirectAttributes redirect, + String status, + java.util.function.Supplier action, + String successTemplate + ) { + try { + Booking booking = action.get(); + redirect.addFlashAttribute("flashMessage", successTemplate.formatted(booking.getCode())); + redirect.addFlashAttribute("flashType", "success"); + } catch (InvalidBookingRequestException | BookingAlreadyCancelledException exception) { + redirect.addFlashAttribute("flashMessage", exception.getMessage()); + redirect.addFlashAttribute("flashType", "error"); + } + // Giữ nguyên bộ lọc đang xem để admin không bị nhảy về "Tất cả" sau mỗi thao tác. + return status == null || status.isBlank() + ? "redirect:/admin/bookings" + : "redirect:/admin/bookings?status=" + status; + } + + // Giá trị lạ trên URL (người dùng sửa tay) coi như không lọc, thay vì ném lỗi 500. + private BookingStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + try { + return BookingStatus.valueOf(status.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return null; + } + } +} diff --git a/src/main/java/demo/tripgo/admin/AdminBookingForm.java b/src/main/java/demo/tripgo/admin/AdminBookingForm.java new file mode 100644 index 0000000..e6e0df8 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminBookingForm.java @@ -0,0 +1,60 @@ +package demo.tripgo.admin; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +// Form admin tạo đơn hộ khách. Ràng buộc số khách và liên hệ giống hệt CreateBookingRequest của +// API, để đơn tạo từ đâu cũng qua cùng một bộ luật. Không có ô giá tiền: server tự tính. +@Getter +@Setter +public class AdminBookingForm { + + @NotNull(message = "Vui lòng chọn tour") + private Long tourId; + + @NotNull(message = "Vui lòng chọn ngày khởi hành") + @FutureOrPresent(message = "Ngày khởi hành phải là hôm nay hoặc sau hôm nay") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate departureDate; + + // Đơn gắn vào tài khoản khách có sẵn tìm theo email này (khách thấy đơn trong "Đơn của tôi"). + @NotBlank(message = "Vui lòng nhập email tài khoản khách") + @Email(message = "Email tài khoản khách không hợp lệ") + private String customerEmail; + + @NotNull(message = "Vui lòng nhập số người lớn") + @Min(value = 1, message = "Phải có ít nhất 1 người lớn") + @Max(value = 100, message = "Số người lớn không được vượt quá 100") + private Integer adults = 1; + + @NotNull(message = "Vui lòng nhập số trẻ em") + @Min(value = 0, message = "Số trẻ em không được nhỏ hơn 0") + @Max(value = 100, message = "Số trẻ em không được vượt quá 100") + private Integer children = 0; + + @NotBlank(message = "Họ tên liên hệ không được để trống") + @Size(max = 100, message = "Họ tên liên hệ không được vượt quá 100 ký tự") + private String contactName; + + @NotBlank(message = "Email liên hệ không được để trống") + @Email(message = "Email liên hệ không hợp lệ") + private String contactEmail; + + @NotBlank(message = "Số điện thoại không được để trống") + @Pattern(regexp = "^(0\\d{9,10}|\\+84\\d{9,10})$", message = "Số điện thoại không hợp lệ") + private String contactPhone; + + @Size(max = 500, message = "Ghi chú không được vượt quá 500 ký tự") + private String note; +} diff --git a/src/main/java/demo/tripgo/admin/AdminBookingRow.java b/src/main/java/demo/tripgo/admin/AdminBookingRow.java new file mode 100644 index 0000000..877ffc4 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminBookingRow.java @@ -0,0 +1,24 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.BookingStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; + +// Một dòng trong bảng đơn đặt. Map trong transaction để template không chạm vào quan hệ lazy. +public record AdminBookingRow( + Long id, + String code, + String customerName, + String customerEmail, + String tourTitle, + LocalDate departureDate, + int guests, + BigDecimal totalPrice, + BookingStatus status +) { + // Chỉ đơn đang chờ mới đổi được trạng thái (hợp đồng: pending -> confirmed/cancelled). + public boolean isActionable() { + return status == BookingStatus.PENDING; + } +} diff --git a/src/main/java/demo/tripgo/admin/AdminDashboardController.java b/src/main/java/demo/tripgo/admin/AdminDashboardController.java new file mode 100644 index 0000000..435f938 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminDashboardController.java @@ -0,0 +1,53 @@ +package demo.tripgo.admin; + +import demo.tripgo.service.DashboardService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.time.YearMonth; +import java.time.format.DateTimeParseException; + +@Controller +public class AdminDashboardController { + + private final DashboardService dashboardService; + + public AdminDashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + + @GetMapping("/admin") + public String dashboard(Model model) { + model.addAttribute("activeMenu", "dashboard"); + model.addAttribute("pageHeading", "Dashboard"); + model.addAttribute("stats", dashboardService.stats()); + // Ô chọn tháng: mặc định và giới hạn trên đều là tháng hiện tại (không chọn tháng tương lai). + model.addAttribute("currentMonth", YearMonth.now().toString()); + return "admin/dashboard"; + } + + // Dữ liệu biểu đồ trả riêng dạng JSON thay vì nhúng vào HTML: tránh chuyện escape JSON trong + // thẻ + + + + diff --git a/src/main/resources/templates/admin/destinations/form.html b/src/main/resources/templates/admin/destinations/form.html new file mode 100644 index 0000000..4dca23a --- /dev/null +++ b/src/main/resources/templates/admin/destinations/form.html @@ -0,0 +1,66 @@ + + + + Điểm đến + + +
+ + + +
+ +
+

Lỗi

+
+ +
+ + +

Lỗi

+
+ +
+ + +

+ Dùng làm tham số lọc công khai ?destination=da-nang. + Đổi slug sẽ làm hỏng các link cũ. +

+

Lỗi

+
+ +
+ + + +
+ Ảnh điểm đến hiện tại + +
+ + +

JPG, PNG, WEBP hoặc GIF, tối đa 5 MB.

+

Lỗi

+
+ +
+ + Huỷ +
+
+ +
+ + diff --git a/src/main/resources/templates/admin/destinations/list.html b/src/main/resources/templates/admin/destinations/list.html new file mode 100644 index 0000000..2f6018c --- /dev/null +++ b/src/main/resources/templates/admin/destinations/list.html @@ -0,0 +1,78 @@ + + + + Điểm đến · TripGo Admin + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ẢnhTênSlugSố tourThao tác
+ + — + Đà Nẵngda-nang0 + Sửa + +
+ +
+
Chưa có điểm đến nào
+ + + +
+ + diff --git a/src/main/resources/templates/admin/destinations/trash.html b/src/main/resources/templates/admin/destinations/trash.html new file mode 100644 index 0000000..003847e --- /dev/null +++ b/src/main/resources/templates/admin/destinations/trash.html @@ -0,0 +1,58 @@ + + + + Thùng rác điểm đến · TripGo Admin + + +
+ + + +

+ Điểm đến ở đây đã ẩn khỏi bộ lọc công khai. Chỉ điểm đến không còn tour nào mới xoá được, + nên khôi phục sẽ không làm tour nào bị ảnh hưởng. +

+ + + + + + + + + + + + + + + + + + + + + +
TênSlugXoá lúcThao tác
Đà Nẵngda-nang-deleted-3— +
+ +
+
Thùng rác trống
+ + + +
+ + diff --git a/src/main/resources/templates/admin/jobs/list.html b/src/main/resources/templates/admin/jobs/list.html new file mode 100644 index 0000000..64bc8f2 --- /dev/null +++ b/src/main/resources/templates/admin/jobs/list.html @@ -0,0 +1,70 @@ + + + + Job nền · TripGo Admin + + +
+ + + + +

+ Lịch chạy tự động đang tắt (TASKS_ENABLED=false). Vẫn bấm "Chạy ngay" được. +

+ +
+
+
+
+

Huỷ đơn quá hạn

+

Mô tả

+
+ Thành công +
+ +
+
Nhịp chạy
+
mỗi 1 giờ
+ +
Lần chạy cuối
+
Chưa chạy kể từ khi khởi động
+
+ 14:05:12 01/10/2026 + (Theo lịch, mất 35 ms, luồng tripgo-task-1) + (đang chạy) +
+ +
Kết quả
+
Lỗi
+
Tìm thấy 3 đơn, đã huỷ 3
+ +
Lần chạy tiếp
+
khoảng 15:05
+
—
+ +
Số lần đã chạy
+
0
+
+ + +
+ +
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html new file mode 100644 index 0000000..1553286 --- /dev/null +++ b/src/main/resources/templates/admin/layout.html @@ -0,0 +1,66 @@ + + + + + + + TripGo Admin + + + + + +
+ + + +
+
+ Quản trị + admin@tripgo +
+ + +
+

Thông báo

+
+ +
+

Nội dung trang

+
+
+ + +
+ +
+ + + + + + diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 0000000..9391c61 --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,42 @@ + + + + + + + Đăng nhập · TripGo Admin + + + + + + + + + diff --git a/src/main/resources/templates/admin/tours/form.html b/src/main/resources/templates/admin/tours/form.html new file mode 100644 index 0000000..2c5dc4b --- /dev/null +++ b/src/main/resources/templates/admin/tours/form.html @@ -0,0 +1,166 @@ + + + + Tour + + +
+ + + + +
+ + +
+

Lỗi

+
+ +
+ + +

Lỗi

+
+ +
+ + +

Phần định danh trên URL công khai, ví dụ da-nang-3n2d.

+

Lỗi

+
+ +
+
+ + +

Lỗi

+
+ +
+ + +

Lỗi

+
+
+ +
+
+ + +

Lỗi

+
+ +
+ + +

Lỗi

+
+
+ +
+
+ + +

Lỗi

+
+ +
+ + +

Lỗi

+
+
+ +
+ + + + + +
+ Ảnh đại diện hiện tại + +
+ + +

JPG, PNG, WEBP hoặc GIF, tối đa 5 MB. Chọn file mới sẽ thay ảnh cũ.

+

Lỗi

+
+ +
+ + + + + +

Chọn nhiều ảnh cùng lúc để thêm vào gallery.

+
+ +
+ + +
+ +
+ Lịch trình + +
+ +
+ Ngày 1 + + + +
+
+ + +

Dòng để trống sẽ bị bỏ qua khi lưu. Số ngày tự đánh lại theo thứ tự.

+
+ +
+ + Huỷ +
+
+ + + +
+ + diff --git a/src/main/resources/templates/admin/tours/import.html b/src/main/resources/templates/admin/tours/import.html new file mode 100644 index 0000000..6dc10ce --- /dev/null +++ b/src/main/resources/templates/admin/tours/import.html @@ -0,0 +1,85 @@ + + + + Nhập tour từ Excel · TripGo Admin + + +
+ + + +
+

+ Cột bắt buộc: Tên tour, Điểm đến, Loại hình, + Giá, Số ngày, Số khách tối đa. + Điểm đến và loại hình nhập bằng slug (vd da-nang, + beach). Cột được khớp theo tên tiêu đề nên thứ tự cột không quan trọng. +

+

+ Dòng lỗi được báo lại kèm số dòng, những dòng còn lại vẫn nhập bình thường. + Tour có slug trùng sẽ bị bỏ qua, không ghi đè. +

+ +

Tải file mẫu

+ +
+

Lỗi

+
+ +
+
+ + +
+
+ +
+
+
+ + +
+

Kết quả

+ +
+
+ Tổng dòng + 0 +
+
+ Đã nhập + 0 +
+
+ Bỏ qua + 0 +
+
+ +

+ Nhập xong, không có dòng nào lỗi. +

+ + + + + + + + + + + + + + +
DòngVấn đề
2Mô tả lỗi
+
+ +
+ + diff --git a/src/main/resources/templates/admin/tours/list.html b/src/main/resources/templates/admin/tours/list.html new file mode 100644 index 0000000..a163790 --- /dev/null +++ b/src/main/resources/templates/admin/tours/list.html @@ -0,0 +1,87 @@ + + + + Tour · TripGo Admin + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tên tourĐiểm đếnGiáThời lượngĐánh giáThao tác
Tên tourĐiểm đến + 0 + 0 + 03 ngày4.5 + Sửa + +
+ +
+
Chưa có tour nào
+ + + + +
+ + diff --git a/src/main/resources/templates/admin/tours/trash.html b/src/main/resources/templates/admin/tours/trash.html new file mode 100644 index 0000000..115088f --- /dev/null +++ b/src/main/resources/templates/admin/tours/trash.html @@ -0,0 +1,60 @@ + + + + Thùng rác · TripGo Admin + + +
+ + + +

+ Tour ở đây đã bị ẩn khỏi trang bán và API công khai, nhưng dữ liệu vẫn còn nguyên — + đơn đặt và đánh giá cũ không bị ảnh hưởng. Khôi phục là tour hiện lại như trước. +

+ + + + + + + + + + + + + + + + + + + + + + + +
IDTên tourĐiểm đếnXoá lúcThao tác
1Tên tourĐiểm đến— +
+ +
+
Thùng rác trống
+ + + +
+ + diff --git a/src/main/resources/templates/admin/users/list.html b/src/main/resources/templates/admin/users/list.html new file mode 100644 index 0000000..627e2a6 --- /dev/null +++ b/src/main/resources/templates/admin/users/list.html @@ -0,0 +1,107 @@ + + + + Người dùng · TripGo Admin + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Người dùngĐăng nhập bằngVai tròTrạng tháiNgày tạoThao tác
+ Nguyễn A + a@example.com + Email / mật khẩu + Người dùng + + Hoạt động + Chưa kích hoạt + Bị khoá + — + 01/01/2026 + +
+ + + + + +
+ +
+ + + + + +
+
Chưa có người dùng nào
+ + + +
+ + diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000..814d910 --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,23 @@ + + + + + + + Không tìm thấy trang · TripGo + + + + + + diff --git a/src/main/resources/templates/error/500.html b/src/main/resources/templates/error/500.html new file mode 100644 index 0000000..f409b5a --- /dev/null +++ b/src/main/resources/templates/error/500.html @@ -0,0 +1,19 @@ + + + + + + + Lỗi hệ thống · TripGo + + + + + + diff --git a/src/test/java/demo/tripgo/PostgresTestBase.java b/src/test/java/demo/tripgo/PostgresTestBase.java new file mode 100644 index 0000000..5f68599 --- /dev/null +++ b/src/test/java/demo/tripgo/PostgresTestBase.java @@ -0,0 +1,29 @@ +package demo.tripgo; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +// Nền cho các test phải chạy trên Postgres THẬT thay vì H2. +// +// Vì sao cần: H2 (kể cả ở MODE=PostgreSQL) chấp nhận nhiều thứ Postgres từ chối. Một bug đã lọt +// qua đúng vì chuyện này — truy vấn có tham số null khiến Postgres bind thành bytea rồi ném +// "function lower(bytea) does not exist", trong khi toàn bộ test H2 vẫn xanh. +// +// Không chuyển CẢ bộ test sang Postgres: container nặng hơn H2 nhiều, mà phần lớn test chỉ kiểm +// logic nghiệp vụ chứ không phụ thuộc phương ngữ SQL. Chỉ những test đụng tới truy vấn viết tay +// mới kế thừa lớp này. +// +// @Container tĩnh + không withReuse: container khởi động một lần cho cả class, dọn sau khi xong. +@Testcontainers +@ActiveProfiles("postgres-test") +public abstract class PostgresTestBase { + + @Container + @ServiceConnection + static final PostgreSQLContainer POSTGRES = + // Trùng phiên bản với docker-compose.yml để test chạy đúng thứ production dùng. + new PostgreSQLContainer<>("postgres:16-alpine"); +} diff --git a/src/test/java/demo/tripgo/TestDataCleaner.java b/src/test/java/demo/tripgo/TestDataCleaner.java new file mode 100644 index 0000000..d759a18 --- /dev/null +++ b/src/test/java/demo/tripgo/TestDataCleaner.java @@ -0,0 +1,66 @@ +package demo.tripgo; + +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.ReviewRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.repository.UserRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +// Xoá sạch dữ liệu nghiệp vụ theo ĐÚNG thứ tự khoá ngoại. +// +// Trước đây mỗi lớp test tự viết lại chuỗi deleteAll() của riêng nó, và ba lần đã vỡ vì cùng một +// lý do: một lớp test mới tạo thêm booking/departure, lớp test cũ gọi tours.deleteAll() trước là +// vi phạm ràng buộc. Thứ tự đó chỉ nên tồn tại ở MỘT chỗ. +// +// Là @Component trong source test: @SpringBootTest quét từ package demo.tripgo nên bean này được +// nạp cho mọi context test, còn khi chạy ứng dụng thật thì source test không nằm trên classpath. +// +// Xoá luôn cả categories. Ban đầu mình để lại vì nhiều test dùng find-or-create theo slug, nhưng +// test nào tự seed danh mục lại đụng lỗi trùng slug — một quy tắc "xoá sạch" dễ đoán hơn là một +// quy tắc có ngoại lệ mà mỗi người phải nhớ. +@Component +public class TestDataCleaner { + + private final BookingRepository bookings; + private final ReviewRepository reviews; + private final DepartureRepository departures; + private final TourRepository tours; + private final DestinationRepository destinations; + private final UserRepository users; + private final CategoryRepository categories; + + public TestDataCleaner( + BookingRepository bookings, + ReviewRepository reviews, + DepartureRepository departures, + TourRepository tours, + DestinationRepository destinations, + UserRepository users, + CategoryRepository categories + ) { + this.bookings = bookings; + this.reviews = reviews; + this.departures = departures; + this.tours = tours; + this.destinations = destinations; + this.users = users; + this.categories = categories; + } + + // Con trỏ tới cha: booking/review/departure -> tour -> destination. User đứng cuối vì + // booking và review đều tham chiếu tới nó. + @Transactional + public void clean() { + bookings.deleteAll(); + reviews.deleteAll(); + departures.deleteAll(); + tours.deleteAll(); + destinations.deleteAll(); + categories.deleteAll(); + users.deleteAll(); + } +} diff --git a/src/test/java/demo/tripgo/admin/AdminBookingCreateIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminBookingCreateIntegrationTest.java new file mode 100644 index 0000000..08c5869 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminBookingCreateIntegrationTest.java @@ -0,0 +1,333 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +// Admin tạo đơn hộ khách: đơn gắn vào tài khoản khách có sẵn, trạng thái Chờ xác nhận, và phải +// qua đúng luật trừ chỗ / tính tiền của luồng khách tự đặt. +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminBookingCreateIntegrationTest { + + private static final String CUSTOMER_EMAIL = "khach@example.com"; + + @Autowired MockMvc mvc; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired UserRepository users; + @Autowired demo.tripgo.TestDataCleaner cleaner; + + private Tour tour; + private Departure departure; + private User customer; + + @BeforeEach + void setUp() { + cleaner.clean(); + + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = new Category(); + category.setSlug("beach"); + category.setName("Biển đảo"); + category = categories.save(category); + + tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("4500000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + departure = saveDeparture(LocalDate.now().plusDays(30), 10, 0); + + customer = new User(); + customer.setEmail(CUSTOMER_EMAIL); + customer.setFullName("Nguyễn Văn Khách"); + customer.setPassword("x"); + customer.setRole(Role.USER); + customer.setStatus(UserStatus.ACTIVE); + customer = users.save(customer); + } + + // ---- Trang form ---- + + @Test + @WithMockUser(roles = "ADMIN") + void listPageLinksToCreateForm() throws Exception { + mvc.perform(get("/admin/bookings")) + .andExpect(content().string(containsString("/admin/bookings/new"))); + } + + // Chưa chọn tour: chỉ có bước chọn tour, chưa hiện form nhập đơn. + @Test + @WithMockUser(roles = "ADMIN") + void formStartsWithTourPicker() throws Exception { + mvc.perform(get("/admin/bookings/new")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/bookings/form")) + .andExpect(content().string(containsString("Đà Nẵng 3N2Đ"))) + .andExpect(content().string(not(containsString("Email tài khoản khách")))); + } + + // Chỉ ngày từ hôm nay trở đi VÀ còn chỗ: ngày đã qua và ngày hết chỗ bị bỏ khỏi danh sách. + @Test + @WithMockUser(roles = "ADMIN") + void listsOnlyUpcomingDeparturesWithSeatsLeft() throws Exception { + saveDeparture(LocalDate.now().minusDays(1), 10, 0); + saveDeparture(LocalDate.now().plusDays(40), 10, 10); + + mvc.perform(get("/admin/bookings/new").param("tourId", tour.getId().toString())) + .andExpect(model().attribute("departures", hasSize(1))) + .andExpect(content().string(containsString("còn 10 chỗ"))) + .andExpect(content().string(containsString("Email tài khoản khách"))); + } + + // Ô chọn khách chỉ có khách (role USER) đang hoạt động: không có admin, không có tài khoản khoá. + @Test + @WithMockUser(roles = "ADMIN") + void customerDropdownListsOnlyActiveCustomers() throws Exception { + saveAccount("bi-khoa@example.com", Role.USER, UserStatus.BLOCKED); + saveAccount("admin@tripgo.local", Role.ADMIN, UserStatus.ACTIVE); + + mvc.perform(get("/admin/bookings/new").param("tourId", tour.getId().toString())) + .andExpect(model().attribute("customers", hasSize(1))) + .andExpect(content().string(containsString("khach@example.com — Nguyễn Văn Khách"))) + .andExpect(content().string(not(containsString("bi-khoa@example.com")))) + .andExpect(content().string(not(containsString("admin@tripgo.local")))); + } + + // Giá trị