From 0d03feeffd80c6f93c7b00dd3ce3de4385700152 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 6 Mar 2026 16:45:43 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20multi-vertical=20expansion?= =?UTF-8?q?=20=E2=80=94=20Spa=20appointments,=20Retail=20POS,=20Cafe=20loy?= =?UTF-8?q?alty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spa/Beauty (booking-service) — Therapist + Appointment scheduling: - Therapist aggregate: specialties (text[]), workingHours (jsonb), CRUD - Appointment: notes field, Pending initial status, MarkNoShow() behavior - TherapistsController (4 endpoints), 9 FluentValidation validators - EF config: PostgreSQL native text[] + jsonb column types Retail POS (catalog + inventory + order) — Barcode, stock, returns: - Product: barcode/SKU fields, GetProductByBarcodeQuery (lookup endpoint) - Inventory: bulk stock check, low stock alert threshold (SetReorderLevel) - Order: return/exchange flow with ProcessReturn(), Returned status (id=8) - CreateReturnCommand, CreateExchangeCommand (same UnitOfWork) - 2 domain events: OrderReturnedDomainEvent, OrderExchangedDomainEvent - 6 new API endpoints across 3 services Cafe (membership + fnb-engine) — Loyalty stamps + barista queue: - StampCard aggregate: AddStamp(), ClaimReward(), Reset(), 4 domain events - Auto-create card on first stamp (friction-free UX) - StampCardsController (6 endpoints), 4 commands, 2 queries - BaristaQueueItem: 5-status workflow (Queued→Preparing→Ready→Delivered) - BaristaController (6 endpoints), 5 commands, 2 queries - Tenant isolation (shop-level) on both features ROADMAP: Phase 1 closed out, Phase 2 vertical tasks IN-PROGRESS Co-Authored-By: Claude Opus 4.6 --- ROADMAP.md | 31 ++- .../Commands/CreateAppointmentCommand.cs | 3 +- .../CreateAppointmentCommandHandler.cs | 4 +- .../Application/Commands/MarkNoShowCommand.cs | 15 ++ .../Commands/MarkNoShowCommandHandler.cs | 70 ++++++ .../Therapist/CreateTherapistCommand.cs | 18 ++ .../CreateTherapistCommandHandler.cs | 73 ++++++ .../Therapist/DeactivateTherapistCommand.cs | 14 ++ .../DeactivateTherapistCommandHandler.cs | 56 +++++ .../Therapist/UpdateTherapistCommand.cs | 18 ++ .../UpdateTherapistCommandHandler.cs | 75 ++++++ .../UpdateAppointmentStatusCommandHandler.cs | 6 +- .../Application/DTOs/AppointmentDto.cs | 1 + .../Application/DTOs/TherapistDto.cs | 20 ++ .../Application/DTOs/WorkingHoursDto.cs | 48 ++++ .../Queries/GetAppointmentQueryHandler.cs | 1 + .../GetAppointmentsByCustomerQueryHandler.cs | 1 + .../GetAppointmentsByShopQueryHandler.cs | 1 + .../Queries/Therapist/GetTherapistsQuery.cs | 16 ++ .../Therapist/GetTherapistsQueryHandler.cs | 52 ++++ .../CancelAppointmentCommandValidator.cs | 27 ++ .../CreateAppointmentCommandValidator.cs | 41 ++++ .../CreateResourceCommandValidator.cs | 37 +++ .../CreateTherapistCommandValidator.cs | 55 +++++ .../DeactivateTherapistCommandValidator.cs | 21 ++ .../FindAvailableSlotsQueryValidator.cs | 35 +++ .../Validations/MarkNoShowCommandValidator.cs | 21 ++ ...UpdateAppointmentStatusCommandValidator.cs | 29 +++ .../UpdateTherapistCommandValidator.cs | 55 +++++ .../Controllers/AppointmentsController.cs | 20 +- .../Controllers/TherapistsController.cs | 122 +++++++++ .../Requests/CreateAppointmentRequest.cs | 1 + .../Models/Requests/CreateTherapistRequest.cs | 18 ++ .../Models/Requests/UpdateTherapistRequest.cs | 17 ++ .../AppointmentAggregate/Appointment.cs | 119 ++++++++- .../ITherapistRepository.cs | 37 +++ .../TherapistAggregate/Therapist.cs | 191 ++++++++++++++ .../Events/BookingDomainEvents.cs | 23 ++ .../BookingContext.cs | 3 + .../DependencyInjection.cs | 2 + .../AppointmentEntityTypeConfiguration.cs | 5 + .../TherapistEntityTypeConfiguration.cs | 71 ++++++ .../Repositories/TherapistRepository.cs | 70 ++++++ .../Domain/AppointmentAggregateTests.cs | 2 +- .../Application/DTOs/ProductDto.cs | 6 + .../Queries/GetProductByBarcodeQuery.cs | 13 + .../GetProductByBarcodeQueryHandler.cs | 70 ++++++ .../Queries/GetProductByIdQueryHandler.cs | 1 + .../Queries/GetProductsQueryHandler.cs | 1 + .../Controllers/ProductsController.cs | 28 +++ .../ProductAggregate/IProductRepository.cs | 6 + .../ProductAggregate/Product.cs | 31 ++- .../ProductEntityTypeConfiguration.cs | 6 + .../Repositories/ProductRepository.cs | 12 + .../Commands/CancelQueueItemCommand.cs | 12 + .../Commands/CancelQueueItemCommandHandler.cs | 42 ++++ .../Commands/MarkDrinkDeliveredCommand.cs | 12 + .../MarkDrinkDeliveredCommandHandler.cs | 42 ++++ .../Commands/MarkDrinkReadyCommand.cs | 12 + .../Commands/MarkDrinkReadyCommandHandler.cs | 42 ++++ .../Application/Commands/QueueDrinkCommand.cs | 32 +++ .../Commands/QueueDrinkCommandHandler.cs | 51 ++++ .../Commands/StartPreparingCommand.cs | 15 ++ .../Commands/StartPreparingCommandHandler.cs | 43 ++++ .../Queries/GetBaristaQueueQuery.cs | 33 +++ .../Queries/GetBaristaQueueQueryHandler.cs | 43 ++++ .../Application/Queries/GetQueueStatsQuery.cs | 26 ++ .../Queries/GetQueueStatsQueryHandler.cs | 34 +++ .../Validations/BaristaCommandValidators.cs | 110 +++++++++ .../Controllers/BaristaController.cs | 186 ++++++++++++++ .../BaristaAggregate/BaristaQueueItem.cs | 232 ++++++++++++++++++ .../IBaristaQueueRepository.cs | 62 +++++ .../Events/DrinkQueuedDomainEvent.cs | 13 + .../Events/DrinkReadyDomainEvent.cs | 13 + .../DependencyInjection.cs | 2 + ...BaristaQueueItemEntityTypeConfiguration.cs | 118 +++++++++ .../FnbEngine.Infrastructure/FnbContext.cs | 9 + .../Repositories/BaristaQueueRepository.cs | 115 +++++++++ .../Commands/InventoryCommandHandlers.cs | 36 +++ .../Application/Commands/InventoryCommands.cs | 9 + .../Application/DTOs/InventoryDtos.cs | 28 +++ .../Application/Queries/InventoryQueries.cs | 8 + .../Queries/InventoryQueryHandlers.cs | 44 ++++ .../Validations/InventoryValidators.cs | 22 ++ .../Controllers/InventoryController.cs | 58 +++++ .../IInventoryRepository.cs | 9 + .../InventoryAggregate/InventoryItem.cs | 13 + .../Repositories/InventoryRepository.cs | 11 + .../Application/Commands/AddStampCommand.cs | 25 ++ .../Commands/AddStampCommandHandler.cs | 67 +++++ .../Commands/ClaimRewardCommand.cs | 19 ++ .../Commands/ClaimRewardCommandHandler.cs | 48 ++++ .../Commands/CreateStampCardCommand.cs | 27 ++ .../Commands/CreateStampCardCommandHandler.cs | 58 +++++ .../Commands/ResetStampCardCommand.cs | 20 ++ .../Commands/ResetStampCardCommandHandler.cs | 48 ++++ .../Application/Queries/GetStampCardQuery.cs | 26 ++ .../Queries/GetStampCardQueryHandler.cs | 39 +++ .../Application/Queries/GetStampCardsQuery.cs | 24 ++ .../Queries/GetStampCardsQueryHandler.cs | 38 +++ .../Validations/AddStampCommandValidator.cs | 22 ++ .../ClaimRewardCommandValidator.cs | 18 ++ .../CreateStampCardCommandValidator.cs | 34 +++ .../ResetStampCardCommandValidator.cs | 18 ++ .../Controllers/StampCardsController.cs | 173 +++++++++++++ .../IStampCardRepository.cs | 50 ++++ .../StampCardAggregate/StampCard.cs | 181 ++++++++++++++ .../Events/RewardClaimedDomainEvent.cs | 10 + .../Events/StampAddedDomainEvent.cs | 10 + .../Events/StampCardCompletedDomainEvent.cs | 10 + .../Events/StampCardCreatedDomainEvent.cs | 10 + .../DependencyInjection.cs | 2 + .../StampCardEntityTypeConfiguration.cs | 106 ++++++++ .../MembershipServiceContext.cs | 7 + .../Repositories/StampCardRepository.cs | 100 ++++++++ .../Commands/CreateExchangeCommand.cs | 18 ++ .../Commands/CreateExchangeCommandHandler.cs | 137 +++++++++++ .../Commands/CreateReturnCommand.cs | 17 ++ .../Commands/CreateReturnCommandHandler.cs | 108 ++++++++ .../Application/DTOs/OrderDtos.cs | 46 ++++ .../Queries/GetOrderReturnsQuery.cs | 13 + .../Queries/GetOrderReturnsQueryHandler.cs | 54 ++++ .../CreateExchangeCommandValidator.cs | 66 +++++ .../CreateReturnCommandValidator.cs | 63 +++++ .../Controllers/OrdersController.cs | 65 +++++ .../OrderAggregate/IOrderRepository.cs | 6 + .../AggregatesModel/OrderAggregate/Order.cs | 49 ++++ .../OrderAggregate/OrderStatus.cs | 6 + .../Events/OrderDomainEvents.cs | 12 + .../OrderEntityTypeConfiguration.cs | 19 ++ .../Repositories/OrderRepository.cs | 12 + 131 files changed, 5109 insertions(+), 28 deletions(-) create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/MarkNoShowCommand.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/MarkNoShowCommandHandler.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommand.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommandHandler.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommand.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommandHandler.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommand.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommandHandler.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/DTOs/TherapistDto.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/DTOs/WorkingHoursDto.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Queries/Therapist/GetTherapistsQuery.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Queries/Therapist/GetTherapistsQueryHandler.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/CancelAppointmentCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/CreateAppointmentCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/CreateResourceCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/CreateTherapistCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/DeactivateTherapistCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/FindAvailableSlotsQueryValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/MarkNoShowCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/UpdateAppointmentStatusCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Application/Validations/UpdateTherapistCommandValidator.cs create mode 100644 services/booking-service-net/src/BookingService.API/Controllers/TherapistsController.cs create mode 100644 services/booking-service-net/src/BookingService.API/Models/Requests/CreateTherapistRequest.cs create mode 100644 services/booking-service-net/src/BookingService.API/Models/Requests/UpdateTherapistRequest.cs create mode 100644 services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/ITherapistRepository.cs create mode 100644 services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/Therapist.cs create mode 100644 services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/TherapistEntityTypeConfiguration.cs create mode 100644 services/booking-service-net/src/BookingService.Infrastructure/Repositories/TherapistRepository.cs create mode 100644 services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQuery.cs create mode 100644 services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQueryHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommand.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommandHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommand.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommandHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommand.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommandHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommand.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommandHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommand.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommandHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQuery.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQueryHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQuery.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQueryHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Validations/BaristaCommandValidators.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Controllers/BaristaController.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/BaristaQueueItem.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/IBaristaQueueRepository.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkQueuedDomainEvent.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkReadyDomainEvent.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/BaristaQueueItemEntityTypeConfiguration.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/BaristaQueueRepository.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQuery.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQueryHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQuery.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQueryHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Validations/AddStampCommandValidator.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Validations/ClaimRewardCommandValidator.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Validations/CreateStampCardCommandValidator.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Validations/ResetStampCardCommandValidator.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Controllers/StampCardsController.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/IStampCardRepository.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/StampCard.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/RewardClaimedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/StampAddedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/StampCardCompletedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/StampCardCreatedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/StampCardEntityTypeConfiguration.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/Repositories/StampCardRepository.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommand.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommandHandler.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommand.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommandHandler.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQuery.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQueryHandler.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Validations/CreateExchangeCommandValidator.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Validations/CreateReturnCommandValidator.cs diff --git a/ROADMAP.md b/ROADMAP.md index db47ff1c..268890ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ | Metric | Current | Phase 1 Target | Phase 2 Target | Phase 3 Target | |--------|:-------:|:--------------:|:--------------:|:--------------:| | Services production-ready | 8/24 | 12/24 | 16/24 | 20/24 | -| Test coverage (estimated) | ~45% | 70% | 80% | 85% | +| Test coverage (estimated) | ~50% | 70% | 80% | 85% | | POS verticals fully working | 2/5 | 2/5 (stable) | 4/5 | 5/5 | | Payment methods live | 0 | 2 | 3 | 4+ | | Real-time features | 0 | KDS + Orders | Full POS | Full | @@ -79,9 +79,9 @@ |----------|:-------:|:--------:|:----------:|:-------:|:------:| | Karaoke | DONE | DONE | DONE | UI-ONLY | WORKING | | Restaurant | DONE | DONE | DONE | UI-ONLY | WORKING | -| Cafe | DONE | DONE | PARTIAL | UI-ONLY | PARTIAL | -| Spa/Beauty | PARTIAL | DONE | PARTIAL | UI-ONLY | PARTIAL | -| Retail | TODO | UI-ONLY | TODO | UI-ONLY | TODO | +| Cafe | DONE | DONE | IN-PROGRESS | UI-ONLY | PARTIAL | +| Spa/Beauty | IN-PROGRESS | DONE | PARTIAL | UI-ONLY | PARTIAL | +| Retail | IN-PROGRESS | UI-ONLY | TODO | UI-ONLY | TODO | --- @@ -109,7 +109,7 @@ | 11 | EOD Reports + Daily Close | `TODO` | Frontend Blazor | Phase 1 / W4 | order-service queries | | 12 | FnB Engine Test Coverage | `DONE` | QA Engineer | Phase 1 / W3 | 96 tests (57 domain + 39 handler) | | 13 | Cafe Workflow Completion | `TODO` | Backend + Frontend | Phase 2 / W5-6 | Loyalty stamps, barista queue | -| 14 | Critical Path Unit Tests (inventory, payment, events) | `IN-PROGRESS` | QA Engineer | Phase 1 / W4 | Deduction, payment callback, domain event handlers | +| 14 | Critical Path Unit Tests (inventory, payment, events) | `DONE` | QA Engineer | Phase 1 / W4 | Deduction, payment callback, domain event handlers | ### P2 — Enhancement @@ -147,8 +147,8 @@ |------|-------|:------:|:----------:| | Kitchen → Inventory auto-deduction | Senior Backend #1 | `DONE` | fnb-engine, inventory | | Row-level security (all services) | Senior Backend #2 | `DONE` | — | -| Rate limiting audit | DevOps | `IN-PROGRESS` | — | -| Input sanitization audit | QA | `IN-PROGRESS` | — | +| Rate limiting audit | DevOps | `DONE` | — | +| Input sanitization audit | QA | `DONE` | — | | FnB Engine unit tests | QA | `DONE` | — | | Order lifecycle integration tests | QA | `DONE` | 29 tests, WebApplicationFactory | @@ -156,7 +156,7 @@ | Task | Agent | Status | Depends On | |------|-------|:------:|:----------:| -| EOD reports + daily close workflow | Senior Frontend | `IN-PROGRESS` | order-service | +| EOD reports + daily close workflow | Senior Frontend | `DONE` | order-service | | Full regression testing | QA | `TODO` | All P0 done | | Staging K8s deployment | DevOps | `DONE` | 16 manifests + CI/CD | | Grafana monitoring dashboards | DevOps | `TODO` | Observability stack | @@ -171,9 +171,9 @@ | Task | Agent | Status | Depends On | |------|-------|:------:|:----------:| -| Spa domain logic (appointments, therapists) | Senior Backend | `TODO` | booking-service | -| Retail POS workflow (scan, stock, returns) | Senior Backend | `TODO` | catalog, inventory | -| Cafe-specific (loyalty stamps, barista queue) | Senior Backend | `TODO` | membership | +| Spa domain logic (appointments, therapists) | Senior Backend | `IN-PROGRESS` | booking-service | +| Retail POS workflow (scan, stock, returns) | Senior Backend | `IN-PROGRESS` | catalog, inventory | +| Cafe-specific (loyalty stamps, barista queue) | Senior Backend | `IN-PROGRESS` | membership | | Vertical-specific UI refinement | Senior Frontend | `TODO` | Backend done | | Multi-branch management features | Senior Backend | `TODO` | merchant-service | @@ -254,6 +254,15 @@ | Add 4 missing databases to init-databases.sh | DevOps | mkt_facebook, mkt_whatsapp, mkt_x, mkt_zalo | | Add Traefik routes (wallet, catalog, booking) | DevOps | Plus /api/v1/stock for inventory | +### 2026-03-06 (Phase 1 Close-out) + +| Task | Agent | Details | +|------|-------|---------| +| EOD Reports + Daily Close | Backend + Frontend | GetEodReportQuery (Dapper), CloseDayCommand, EodReport.razor (6 KPIs, charts, top items) | +| Rate Limiting (4 tiers) | DevOps | auth (10/min), payment (30/min), api (100/min), hub (500/min) across all Traefik routers | +| Input Sanitization (44 validators) | QA + Backend | FluentValidation for all unprotected commands across 8 services | +| Critical Path Tests (30 tests) | QA | Inventory deduction (12), payment create/callback (14), kitchen event handler (8) | + ### 2026-03-05 | Task | Agent | Details | diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommand.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommand.cs index be472143..31b8e44a 100644 --- a/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommand.cs +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommand.cs @@ -17,5 +17,6 @@ public record CreateAppointmentCommand( DateTime EndTime, Guid? CustomerId = null, Guid? StaffId = null, - Guid? ResourceId = null + Guid? ResourceId = null, + string? Notes = null ) : IRequest; diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommandHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommandHandler.cs index 73224535..ef0b502d 100644 --- a/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommandHandler.cs +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/CreateAppointmentCommandHandler.cs @@ -40,7 +40,8 @@ public class CreateAppointmentCommandHandler : IRequestHandler +/// EN: Command to mark an appointment as no-show (customer didn't arrive). +/// VI: Command để đánh dấu cuộc hẹn là không đến (khách không đến). +/// +public record MarkNoShowCommand( + Guid AppointmentId +) : IRequest; diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/MarkNoShowCommandHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/MarkNoShowCommandHandler.cs new file mode 100644 index 00000000..3ebbfde3 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/MarkNoShowCommandHandler.cs @@ -0,0 +1,70 @@ +// EN: Handler for MarkNoShowCommand. +// VI: Handler cho MarkNoShowCommand. + +using BookingService.API.Application.DTOs; +using BookingService.Domain.Exceptions; +using BookingService.Infrastructure.Repositories; +using MediatR; + +namespace BookingService.API.Application.Commands; + +/// +/// EN: Handler for marking an appointment as no-show. +/// VI: Handler để đánh dấu cuộc hẹn là không đến. +/// +public class MarkNoShowCommandHandler : IRequestHandler +{ + private readonly IAppointmentRepository _appointmentRepository; + private readonly ILogger _logger; + + public MarkNoShowCommandHandler( + IAppointmentRepository appointmentRepository, + ILogger logger) + { + _appointmentRepository = appointmentRepository ?? throw new ArgumentNullException(nameof(appointmentRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(MarkNoShowCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Marking appointment {AppointmentId} as no-show / Đánh dấu cuộc hẹn {AppointmentId} là không đến", + request.AppointmentId); + + // EN: Load appointment from repository + // VI: Load cuộc hẹn từ repository + var appointment = await _appointmentRepository.GetByIdAsync(request.AppointmentId, cancellationToken); + if (appointment == null) + { + throw new DomainException($"Appointment {request.AppointmentId} not found / Không tìm thấy cuộc hẹn {request.AppointmentId}"); + } + + // EN: Mark as no-show via domain behavior + // VI: Đánh dấu không đến qua hành vi domain + appointment.MarkNoShow(); + + // EN: Save changes + // VI: Lưu thay đổi + _appointmentRepository.Update(appointment); + await _appointmentRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Marked appointment {AppointmentId} as NoShow / Đã đánh dấu cuộc hẹn {AppointmentId} là không đến", + appointment.Id); + + return new AppointmentDto + { + Id = appointment.Id, + ShopId = appointment.ShopId, + CustomerId = appointment.CustomerId, + StaffId = appointment.StaffId, + ResourceId = appointment.ResourceId, + ServiceId = appointment.ServiceId, + StartTime = appointment.StartTime, + EndTime = appointment.EndTime, + Status = appointment.Status, + Notes = appointment.Notes, + CreatedAt = appointment.CreatedAt + }; + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommand.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommand.cs new file mode 100644 index 00000000..ef274e6d --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommand.cs @@ -0,0 +1,18 @@ +// EN: Command to create a new therapist. +// VI: Command tạo chuyên viên mới. + +using BookingService.API.Application.DTOs; +using MediatR; + +namespace BookingService.API.Application.Commands.Therapist; + +/// +/// EN: Command to create a new therapist for a spa/beauty shop. +/// VI: Command để tạo chuyên viên mới cho shop spa/beauty. +/// +public record CreateTherapistCommand( + Guid ShopId, + string Name, + string[] Specialties, + WorkingHoursDto WorkingHours +) : IRequest; diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommandHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommandHandler.cs new file mode 100644 index 00000000..eb3a523a --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/CreateTherapistCommandHandler.cs @@ -0,0 +1,73 @@ +// EN: Handler for CreateTherapistCommand. +// VI: Handler cho CreateTherapistCommand. + +using System.Text.Json; +using BookingService.API.Application.DTOs; +using BookingService.Domain.AggregatesModel.TherapistAggregate; +using MediatR; + +namespace BookingService.API.Application.Commands.Therapist; + +/// +/// EN: Handler for creating new therapists. +/// VI: Handler để tạo chuyên viên mới. +/// +public class CreateTherapistCommandHandler : IRequestHandler +{ + private readonly ITherapistRepository _therapistRepository; + private readonly ILogger _logger; + + public CreateTherapistCommandHandler( + ITherapistRepository therapistRepository, + ILogger logger) + { + _therapistRepository = therapistRepository ?? throw new ArgumentNullException(nameof(therapistRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CreateTherapistCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Creating therapist {Name} for shop {ShopId} / Tạo chuyên viên {Name} cho shop {ShopId}", + request.Name, request.ShopId); + + // EN: Serialize working hours to JSON for domain entity + // VI: Serialize giờ làm việc sang JSON cho domain entity + var workingHoursJson = JsonSerializer.Serialize(request.WorkingHours, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + // EN: Create therapist via domain aggregate + // VI: Tạo chuyên viên qua domain aggregate + var therapist = new Domain.AggregatesModel.TherapistAggregate.Therapist( + request.ShopId, + request.Name, + request.Specialties, + workingHoursJson + ); + + // EN: Save to repository + // VI: Lưu vào repository + _therapistRepository.Add(therapist); + await _therapistRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Created therapist {TherapistId} / Đã tạo chuyên viên {TherapistId}", + therapist.Id); + + // EN: Return DTO + // VI: Trả về DTO + return new TherapistDto + { + Id = therapist.Id, + ShopId = therapist.ShopId, + Name = therapist.Name, + Specialties = therapist.Specialties, + WorkingHours = therapist.WorkingHours, + IsActive = therapist.IsActive, + CreatedAt = therapist.CreatedAt, + UpdatedAt = therapist.UpdatedAt + }; + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommand.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommand.cs new file mode 100644 index 00000000..84d1c375 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommand.cs @@ -0,0 +1,14 @@ +// EN: Command to deactivate a therapist. +// VI: Command vô hiệu hóa chuyên viên. + +using MediatR; + +namespace BookingService.API.Application.Commands.Therapist; + +/// +/// EN: Command to deactivate a therapist (soft delete). +/// VI: Command để vô hiệu hóa chuyên viên (xóa mềm). +/// +public record DeactivateTherapistCommand( + Guid TherapistId +) : IRequest; diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommandHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommandHandler.cs new file mode 100644 index 00000000..cceabb7b --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/DeactivateTherapistCommandHandler.cs @@ -0,0 +1,56 @@ +// EN: Handler for DeactivateTherapistCommand. +// VI: Handler cho DeactivateTherapistCommand. + +using BookingService.Domain.AggregatesModel.TherapistAggregate; +using BookingService.Domain.Exceptions; +using MediatR; + +namespace BookingService.API.Application.Commands.Therapist; + +/// +/// EN: Handler for deactivating a therapist. +/// VI: Handler để vô hiệu hóa chuyên viên. +/// +public class DeactivateTherapistCommandHandler : IRequestHandler +{ + private readonly ITherapistRepository _therapistRepository; + private readonly ILogger _logger; + + public DeactivateTherapistCommandHandler( + ITherapistRepository therapistRepository, + ILogger logger) + { + _therapistRepository = therapistRepository ?? throw new ArgumentNullException(nameof(therapistRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(DeactivateTherapistCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Deactivating therapist {TherapistId} / Vô hiệu hóa chuyên viên {TherapistId}", + request.TherapistId); + + // EN: Load therapist from repository + // VI: Load chuyên viên từ repository + var therapist = await _therapistRepository.GetByIdAsync(request.TherapistId, cancellationToken); + if (therapist == null) + { + throw new DomainException($"Therapist {request.TherapistId} not found / Không tìm thấy chuyên viên {request.TherapistId}"); + } + + // EN: Deactivate via domain behavior method + // VI: Vô hiệu hóa qua phương thức hành vi domain + therapist.Deactivate(); + + // EN: Save changes + // VI: Lưu thay đổi + _therapistRepository.Update(therapist); + await _therapistRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Deactivated therapist {TherapistId} / Đã vô hiệu hóa chuyên viên {TherapistId}", + therapist.Id); + + return true; + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommand.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommand.cs new file mode 100644 index 00000000..8165c2b8 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommand.cs @@ -0,0 +1,18 @@ +// EN: Command to update a therapist. +// VI: Command cập nhật chuyên viên. + +using BookingService.API.Application.DTOs; +using MediatR; + +namespace BookingService.API.Application.Commands.Therapist; + +/// +/// EN: Command to update an existing therapist's details. +/// VI: Command để cập nhật thông tin chuyên viên hiện có. +/// +public record UpdateTherapistCommand( + Guid TherapistId, + string Name, + string[] Specialties, + WorkingHoursDto WorkingHours +) : IRequest; diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommandHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommandHandler.cs new file mode 100644 index 00000000..115fa9fa --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/Therapist/UpdateTherapistCommandHandler.cs @@ -0,0 +1,75 @@ +// EN: Handler for UpdateTherapistCommand. +// VI: Handler cho UpdateTherapistCommand. + +using System.Text.Json; +using BookingService.API.Application.DTOs; +using BookingService.Domain.AggregatesModel.TherapistAggregate; +using BookingService.Domain.Exceptions; +using MediatR; + +namespace BookingService.API.Application.Commands.Therapist; + +/// +/// EN: Handler for updating therapist details. +/// VI: Handler để cập nhật thông tin chuyên viên. +/// +public class UpdateTherapistCommandHandler : IRequestHandler +{ + private readonly ITherapistRepository _therapistRepository; + private readonly ILogger _logger; + + public UpdateTherapistCommandHandler( + ITherapistRepository therapistRepository, + ILogger logger) + { + _therapistRepository = therapistRepository ?? throw new ArgumentNullException(nameof(therapistRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(UpdateTherapistCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Updating therapist {TherapistId} / Cập nhật chuyên viên {TherapistId}", + request.TherapistId); + + // EN: Load therapist from repository + // VI: Load chuyên viên từ repository + var therapist = await _therapistRepository.GetByIdAsync(request.TherapistId, cancellationToken); + if (therapist == null) + { + throw new DomainException($"Therapist {request.TherapistId} not found / Không tìm thấy chuyên viên {request.TherapistId}"); + } + + // EN: Serialize working hours to JSON + // VI: Serialize giờ làm việc sang JSON + var workingHoursJson = JsonSerializer.Serialize(request.WorkingHours, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + // EN: Update via domain behavior method + // VI: Cập nhật qua phương thức hành vi domain + therapist.Update(request.Name, request.Specialties, workingHoursJson); + + // EN: Save changes + // VI: Lưu thay đổi + _therapistRepository.Update(therapist); + await _therapistRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Updated therapist {TherapistId} / Đã cập nhật chuyên viên {TherapistId}", + therapist.Id); + + return new TherapistDto + { + Id = therapist.Id, + ShopId = therapist.ShopId, + Name = therapist.Name, + Specialties = therapist.Specialties, + WorkingHours = therapist.WorkingHours, + IsActive = therapist.IsActive, + CreatedAt = therapist.CreatedAt, + UpdatedAt = therapist.UpdatedAt + }; + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Commands/UpdateAppointmentStatusCommandHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Commands/UpdateAppointmentStatusCommandHandler.cs index eed00ecd..f8d9b8cd 100644 --- a/services/booking-service-net/src/BookingService.API/Application/Commands/UpdateAppointmentStatusCommandHandler.cs +++ b/services/booking-service-net/src/BookingService.API/Application/Commands/UpdateAppointmentStatusCommandHandler.cs @@ -52,8 +52,11 @@ public class UpdateAppointmentStatusCommandHandler : IRequestHandler +/// EN: Therapist DTO for API responses. +/// VI: DTO Therapist cho API responses. +/// +public record TherapistDto +{ + public Guid Id { get; init; } + public Guid ShopId { get; init; } + public string Name { get; init; } = null!; + public string[] Specialties { get; init; } = []; + public string WorkingHours { get; init; } = null!; + public bool IsActive { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime? UpdatedAt { get; init; } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/DTOs/WorkingHoursDto.cs b/services/booking-service-net/src/BookingService.API/Application/DTOs/WorkingHoursDto.cs new file mode 100644 index 00000000..1a818c18 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/DTOs/WorkingHoursDto.cs @@ -0,0 +1,48 @@ +// EN: Working hours DTO for therapist schedule. +// VI: DTO giờ làm việc cho lịch chuyên viên. + +namespace BookingService.API.Application.DTOs; + +/// +/// EN: Working hours configuration for a therapist. +/// VI: Cấu hình giờ làm việc cho chuyên viên. +/// +public record WorkingHoursDto +{ + /// + /// EN: Schedule entries per day of week. + /// VI: Các mục lịch theo ngày trong tuần. + /// + public List Days { get; init; } = new(); +} + +/// +/// EN: Schedule for a single day of the week. +/// VI: Lịch cho một ngày trong tuần. +/// +public record DayScheduleDto +{ + /// + /// EN: Day of week (0=Sunday, 6=Saturday). + /// VI: Ngày trong tuần (0=Chủ nhật, 6=Thứ bảy). + /// + public int DayOfWeek { get; init; } + + /// + /// EN: Whether the therapist works this day. + /// VI: Chuyên viên có làm việc ngày này không. + /// + public bool IsWorking { get; init; } + + /// + /// EN: Start time (HH:mm format). + /// VI: Thời gian bắt đầu (định dạng HH:mm). + /// + public string? StartTime { get; init; } + + /// + /// EN: End time (HH:mm format). + /// VI: Thời gian kết thúc (định dạng HH:mm). + /// + public string? EndTime { get; init; } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Queries/GetAppointmentQueryHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Queries/GetAppointmentQueryHandler.cs index 38bf6e00..35bd9896 100644 --- a/services/booking-service-net/src/BookingService.API/Application/Queries/GetAppointmentQueryHandler.cs +++ b/services/booking-service-net/src/BookingService.API/Application/Queries/GetAppointmentQueryHandler.cs @@ -40,6 +40,7 @@ public class GetAppointmentQueryHandler : IRequestHandler +/// EN: Query to get all therapists for a shop with optional active filter. +/// VI: Query để lấy tất cả chuyên viên cho shop với tùy chọn lọc hoạt động. +/// +public record GetTherapistsQuery( + Guid ShopId, + bool? IsActive = null +) : IRequest>; diff --git a/services/booking-service-net/src/BookingService.API/Application/Queries/Therapist/GetTherapistsQueryHandler.cs b/services/booking-service-net/src/BookingService.API/Application/Queries/Therapist/GetTherapistsQueryHandler.cs new file mode 100644 index 00000000..fd08d393 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Queries/Therapist/GetTherapistsQueryHandler.cs @@ -0,0 +1,52 @@ +// EN: Handler for GetTherapistsQuery. +// VI: Handler cho GetTherapistsQuery. + +using BookingService.API.Application.DTOs; +using BookingService.Domain.AggregatesModel.TherapistAggregate; +using MediatR; + +namespace BookingService.API.Application.Queries.Therapist; + +/// +/// EN: Handler for getting therapists by shop with optional active filter. +/// VI: Handler để lấy chuyên viên theo shop với tùy chọn lọc hoạt động. +/// +public class GetTherapistsQueryHandler : IRequestHandler> +{ + private readonly ITherapistRepository _therapistRepository; + private readonly ILogger _logger; + + public GetTherapistsQueryHandler( + ITherapistRepository therapistRepository, + ILogger logger) + { + _therapistRepository = therapistRepository ?? throw new ArgumentNullException(nameof(therapistRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> Handle(GetTherapistsQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Getting therapists for shop {ShopId} / Lấy chuyên viên cho shop {ShopId}", + request.ShopId); + + // EN: Get therapists from repository + // VI: Lấy chuyên viên từ repository + var therapists = await _therapistRepository.GetByShopIdAsync( + request.ShopId, request.IsActive, cancellationToken); + + // EN: Map to DTOs + // VI: Map sang DTOs + return therapists.Select(t => new TherapistDto + { + Id = t.Id, + ShopId = t.ShopId, + Name = t.Name, + Specialties = t.Specialties, + WorkingHours = t.WorkingHours, + IsActive = t.IsActive, + CreatedAt = t.CreatedAt, + UpdatedAt = t.UpdatedAt + }).ToList(); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/CancelAppointmentCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/CancelAppointmentCommandValidator.cs new file mode 100644 index 00000000..39fe1970 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/CancelAppointmentCommandValidator.cs @@ -0,0 +1,27 @@ +// EN: FluentValidation validator for CancelAppointmentCommand. +// VI: FluentValidation validator cho CancelAppointmentCommand. + +using BookingService.API.Application.Commands; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for CancelAppointmentCommand. +/// VI: Validator cho CancelAppointmentCommand. +/// +public class CancelAppointmentCommandValidator : AbstractValidator +{ + public CancelAppointmentCommandValidator() + { + RuleFor(x => x.AppointmentId) + .NotEmpty() + .WithMessage("Appointment ID is required / ID cuộc hẹn là bắt buộc"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("Cancellation reason is required / Lý do hủy là bắt buộc") + .MaximumLength(500) + .WithMessage("Reason cannot exceed 500 characters / Lý do không được vượt quá 500 ký tự"); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/CreateAppointmentCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/CreateAppointmentCommandValidator.cs new file mode 100644 index 00000000..64b3dcf7 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/CreateAppointmentCommandValidator.cs @@ -0,0 +1,41 @@ +// EN: FluentValidation validator for CreateAppointmentCommand. +// VI: FluentValidation validator cho CreateAppointmentCommand. + +using BookingService.API.Application.Commands; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for CreateAppointmentCommand ensuring all required fields are valid. +/// VI: Validator cho CreateAppointmentCommand đảm bảo tất cả trường bắt buộc hợp lệ. +/// +public class CreateAppointmentCommandValidator : AbstractValidator +{ + public CreateAppointmentCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID là bắt buộc"); + + RuleFor(x => x.ServiceId) + .NotEmpty() + .WithMessage("Service ID is required / Service ID là bắt buộc"); + + RuleFor(x => x.StartTime) + .NotEmpty() + .WithMessage("Start time is required / Thời gian bắt đầu là bắt buộc") + .GreaterThan(DateTime.UtcNow.AddMinutes(-5)) + .WithMessage("Start time must be in the future / Thời gian bắt đầu phải ở tương lai"); + + RuleFor(x => x.EndTime) + .NotEmpty() + .WithMessage("End time is required / Thời gian kết thúc là bắt buộc") + .GreaterThan(x => x.StartTime) + .WithMessage("End time must be after start time / Thời gian kết thúc phải sau thời gian bắt đầu"); + + RuleFor(x => x.Notes) + .MaximumLength(1000) + .WithMessage("Notes cannot exceed 1000 characters / Ghi chú không được vượt quá 1000 ký tự"); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/CreateResourceCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/CreateResourceCommandValidator.cs new file mode 100644 index 00000000..87d500d5 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/CreateResourceCommandValidator.cs @@ -0,0 +1,37 @@ +// EN: FluentValidation validator for CreateResourceCommand. +// VI: FluentValidation validator cho CreateResourceCommand. + +using BookingService.API.Application.Commands; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for CreateResourceCommand. +/// VI: Validator cho CreateResourceCommand. +/// +public class CreateResourceCommandValidator : AbstractValidator +{ + public CreateResourceCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID là bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Resource name is required / Tên tài nguyên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name cannot exceed 200 characters / Tên không được vượt quá 200 ký tự"); + + RuleFor(x => x.ResourceType) + .NotEmpty() + .WithMessage("Resource type is required / Loại tài nguyên là bắt buộc") + .MaximumLength(50) + .WithMessage("Resource type cannot exceed 50 characters / Loại tài nguyên không được vượt quá 50 ký tự"); + + RuleFor(x => x.Capacity) + .GreaterThan(0) + .WithMessage("Capacity must be greater than 0 / Sức chứa phải lớn hơn 0"); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/CreateTherapistCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/CreateTherapistCommandValidator.cs new file mode 100644 index 00000000..4fcd5bca --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/CreateTherapistCommandValidator.cs @@ -0,0 +1,55 @@ +// EN: FluentValidation validator for CreateTherapistCommand. +// VI: FluentValidation validator cho CreateTherapistCommand. + +using BookingService.API.Application.Commands.Therapist; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for CreateTherapistCommand ensuring all required fields are valid. +/// VI: Validator cho CreateTherapistCommand đảm bảo tất cả trường bắt buộc hợp lệ. +/// +public class CreateTherapistCommandValidator : AbstractValidator +{ + public CreateTherapistCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID là bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Therapist name is required / Tên chuyên viên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name cannot exceed 200 characters / Tên không được vượt quá 200 ký tự"); + + RuleFor(x => x.WorkingHours) + .NotNull() + .WithMessage("Working hours are required / Giờ làm việc là bắt buộc"); + + RuleFor(x => x.WorkingHours.Days) + .NotEmpty() + .When(x => x.WorkingHours != null) + .WithMessage("At least one working day must be specified / Phải chỉ định ít nhất một ngày làm việc"); + + RuleForEach(x => x.WorkingHours.Days) + .ChildRules(day => + { + day.RuleFor(d => d.DayOfWeek) + .InclusiveBetween(0, 6) + .WithMessage("Day of week must be between 0 (Sunday) and 6 (Saturday) / Ngày trong tuần phải từ 0 (Chủ nhật) đến 6 (Thứ bảy)"); + + day.RuleFor(d => d.StartTime) + .NotEmpty() + .When(d => d.IsWorking) + .WithMessage("Start time is required for working days / Thời gian bắt đầu là bắt buộc cho ngày làm việc"); + + day.RuleFor(d => d.EndTime) + .NotEmpty() + .When(d => d.IsWorking) + .WithMessage("End time is required for working days / Thời gian kết thúc là bắt buộc cho ngày làm việc"); + }) + .When(x => x.WorkingHours?.Days != null); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/DeactivateTherapistCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/DeactivateTherapistCommandValidator.cs new file mode 100644 index 00000000..868b5248 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/DeactivateTherapistCommandValidator.cs @@ -0,0 +1,21 @@ +// EN: FluentValidation validator for DeactivateTherapistCommand. +// VI: FluentValidation validator cho DeactivateTherapistCommand. + +using BookingService.API.Application.Commands.Therapist; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for DeactivateTherapistCommand. +/// VI: Validator cho DeactivateTherapistCommand. +/// +public class DeactivateTherapistCommandValidator : AbstractValidator +{ + public DeactivateTherapistCommandValidator() + { + RuleFor(x => x.TherapistId) + .NotEmpty() + .WithMessage("Therapist ID is required / ID chuyên viên là bắt buộc"); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/FindAvailableSlotsQueryValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/FindAvailableSlotsQueryValidator.cs new file mode 100644 index 00000000..ca28c146 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/FindAvailableSlotsQueryValidator.cs @@ -0,0 +1,35 @@ +// EN: FluentValidation validator for FindAvailableSlotsQuery. +// VI: FluentValidation validator cho FindAvailableSlotsQuery. + +using BookingService.API.Application.Queries; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for FindAvailableSlotsQuery. +/// VI: Validator cho FindAvailableSlotsQuery. +/// +public class FindAvailableSlotsQueryValidator : AbstractValidator +{ + public FindAvailableSlotsQueryValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID là bắt buộc"); + + RuleFor(x => x.ServiceId) + .NotEmpty() + .WithMessage("Service ID is required / Service ID là bắt buộc"); + + RuleFor(x => x.Date) + .NotEmpty() + .WithMessage("Date is required / Ngày là bắt buộc"); + + RuleFor(x => x.ServiceDurationMinutes) + .GreaterThan(0) + .WithMessage("Service duration must be greater than 0 / Thời lượng dịch vụ phải lớn hơn 0") + .LessThanOrEqualTo(480) + .WithMessage("Service duration cannot exceed 8 hours / Thời lượng dịch vụ không được vượt quá 8 giờ"); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/MarkNoShowCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/MarkNoShowCommandValidator.cs new file mode 100644 index 00000000..b4b4a8e7 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/MarkNoShowCommandValidator.cs @@ -0,0 +1,21 @@ +// EN: FluentValidation validator for MarkNoShowCommand. +// VI: FluentValidation validator cho MarkNoShowCommand. + +using BookingService.API.Application.Commands; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for MarkNoShowCommand. +/// VI: Validator cho MarkNoShowCommand. +/// +public class MarkNoShowCommandValidator : AbstractValidator +{ + public MarkNoShowCommandValidator() + { + RuleFor(x => x.AppointmentId) + .NotEmpty() + .WithMessage("Appointment ID is required / ID cuộc hẹn là bắt buộc"); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/UpdateAppointmentStatusCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/UpdateAppointmentStatusCommandValidator.cs new file mode 100644 index 00000000..863edade --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/UpdateAppointmentStatusCommandValidator.cs @@ -0,0 +1,29 @@ +// EN: FluentValidation validator for UpdateAppointmentStatusCommand. +// VI: FluentValidation validator cho UpdateAppointmentStatusCommand. + +using BookingService.API.Application.Commands; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for UpdateAppointmentStatusCommand. +/// VI: Validator cho UpdateAppointmentStatusCommand. +/// +public class UpdateAppointmentStatusCommandValidator : AbstractValidator +{ + private static readonly string[] ValidActions = ["confirm", "start", "complete", "noshow"]; + + public UpdateAppointmentStatusCommandValidator() + { + RuleFor(x => x.AppointmentId) + .NotEmpty() + .WithMessage("Appointment ID is required / ID cuộc hẹn là bắt buộc"); + + RuleFor(x => x.Action) + .NotEmpty() + .WithMessage("Action is required / Hành động là bắt buộc") + .Must(action => ValidActions.Contains(action.ToLowerInvariant())) + .WithMessage("Action must be one of: confirm, start, complete, noshow / Hành động phải là một trong: confirm, start, complete, noshow"); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Validations/UpdateTherapistCommandValidator.cs b/services/booking-service-net/src/BookingService.API/Application/Validations/UpdateTherapistCommandValidator.cs new file mode 100644 index 00000000..65eded26 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Application/Validations/UpdateTherapistCommandValidator.cs @@ -0,0 +1,55 @@ +// EN: FluentValidation validator for UpdateTherapistCommand. +// VI: FluentValidation validator cho UpdateTherapistCommand. + +using BookingService.API.Application.Commands.Therapist; +using FluentValidation; + +namespace BookingService.API.Application.Validations; + +/// +/// EN: Validator for UpdateTherapistCommand ensuring all required fields are valid. +/// VI: Validator cho UpdateTherapistCommand đảm bảo tất cả trường bắt buộc hợp lệ. +/// +public class UpdateTherapistCommandValidator : AbstractValidator +{ + public UpdateTherapistCommandValidator() + { + RuleFor(x => x.TherapistId) + .NotEmpty() + .WithMessage("Therapist ID is required / ID chuyên viên là bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Therapist name is required / Tên chuyên viên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name cannot exceed 200 characters / Tên không được vượt quá 200 ký tự"); + + RuleFor(x => x.WorkingHours) + .NotNull() + .WithMessage("Working hours are required / Giờ làm việc là bắt buộc"); + + RuleFor(x => x.WorkingHours.Days) + .NotEmpty() + .When(x => x.WorkingHours != null) + .WithMessage("At least one working day must be specified / Phải chỉ định ít nhất một ngày làm việc"); + + RuleForEach(x => x.WorkingHours.Days) + .ChildRules(day => + { + day.RuleFor(d => d.DayOfWeek) + .InclusiveBetween(0, 6) + .WithMessage("Day of week must be between 0 (Sunday) and 6 (Saturday) / Ngày trong tuần phải từ 0 (Chủ nhật) đến 6 (Thứ bảy)"); + + day.RuleFor(d => d.StartTime) + .NotEmpty() + .When(d => d.IsWorking) + .WithMessage("Start time is required for working days / Thời gian bắt đầu là bắt buộc cho ngày làm việc"); + + day.RuleFor(d => d.EndTime) + .NotEmpty() + .When(d => d.IsWorking) + .WithMessage("End time is required for working days / Thời gian kết thúc là bắt buộc cho ngày làm việc"); + }) + .When(x => x.WorkingHours?.Days != null); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Controllers/AppointmentsController.cs b/services/booking-service-net/src/BookingService.API/Controllers/AppointmentsController.cs index 9eba4047..1bd9ea36 100644 --- a/services/booking-service-net/src/BookingService.API/Controllers/AppointmentsController.cs +++ b/services/booking-service-net/src/BookingService.API/Controllers/AppointmentsController.cs @@ -101,7 +101,8 @@ public class AppointmentsController : ControllerBase request.EndTime, request.CustomerId, request.StaffId, - request.ResourceId + request.ResourceId, + request.Notes ); var result = await _mediator.Send(command, cancellationToken); @@ -130,6 +131,23 @@ public class AppointmentsController : ControllerBase return Ok(ApiResponse.Ok(result, "Status updated successfully")); } + /// + /// EN: Mark an appointment as no-show. + /// VI: Đánh dấu cuộc hẹn là không đến. + /// + [HttpPatch("{id:guid}/noshow")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> MarkNoShow( + Guid id, + CancellationToken cancellationToken = default) + { + var command = new MarkNoShowCommand(id); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(result, "Appointment marked as no-show / Cuộc hẹn đã đánh dấu không đến")); + } + /// /// EN: Cancel an appointment. /// VI: Hủy cuộc hẹn. diff --git a/services/booking-service-net/src/BookingService.API/Controllers/TherapistsController.cs b/services/booking-service-net/src/BookingService.API/Controllers/TherapistsController.cs new file mode 100644 index 00000000..7a02eeb7 --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Controllers/TherapistsController.cs @@ -0,0 +1,122 @@ +// EN: Therapists Controller - Spa/Beauty therapist management APIs. +// VI: Controller Therapists - APIs quản lý chuyên viên Spa/Beauty. + +using BookingService.API.Application.Commands.Therapist; +using BookingService.API.Application.DTOs; +using BookingService.API.Application.Queries.Therapist; +using BookingService.API.Models.Requests; +using BookingService.API.Models.Responses; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace BookingService.API.Controllers; + +/// +/// EN: Controller for managing spa/beauty therapists. +/// VI: Controller quản lý chuyên viên spa/beauty. +/// +[ApiController] +[Route("api/v1/therapists")] +[Produces("application/json")] +public class TherapistsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public TherapistsController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get therapists by shop with optional active filter. + /// VI: Lấy danh sách chuyên viên theo shop với tùy chọn lọc hoạt động. + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>>> GetTherapists( + [FromQuery] Guid shopId, + [FromQuery] bool? isActive = null, + CancellationToken cancellationToken = default) + { + if (shopId == Guid.Empty) + { + return BadRequest(ApiResponse>.Fail( + "Shop ID is required / Shop ID là bắt buộc")); + } + + var query = new GetTherapistsQuery(shopId, isActive); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(ApiResponse>.Ok(result)); + } + + /// + /// EN: Create a new therapist. + /// VI: Tạo chuyên viên mới. + /// + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> CreateTherapist( + [FromBody] CreateTherapistRequest request, + CancellationToken cancellationToken = default) + { + var command = new CreateTherapistCommand( + request.ShopId, + request.Name, + request.Specialties, + request.WorkingHours + ); + + var result = await _mediator.Send(command, cancellationToken); + + return CreatedAtAction( + nameof(GetTherapists), + new { shopId = result.ShopId }, + ApiResponse.Ok(result, "Therapist created successfully / Tạo chuyên viên thành công")); + } + + /// + /// EN: Update a therapist. + /// VI: Cập nhật chuyên viên. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> UpdateTherapist( + Guid id, + [FromBody] UpdateTherapistRequest request, + CancellationToken cancellationToken = default) + { + var command = new UpdateTherapistCommand( + id, + request.Name, + request.Specialties, + request.WorkingHours + ); + + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(result, "Therapist updated successfully / Cập nhật chuyên viên thành công")); + } + + /// + /// EN: Deactivate a therapist (soft delete). + /// VI: Vô hiệu hóa chuyên viên (xóa mềm). + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> DeactivateTherapist( + Guid id, + CancellationToken cancellationToken = default) + { + var command = new DeactivateTherapistCommand(id); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(result, "Therapist deactivated successfully / Vô hiệu hóa chuyên viên thành công")); + } +} diff --git a/services/booking-service-net/src/BookingService.API/Models/Requests/CreateAppointmentRequest.cs b/services/booking-service-net/src/BookingService.API/Models/Requests/CreateAppointmentRequest.cs index 1bc4b7dc..36f6cf37 100644 --- a/services/booking-service-net/src/BookingService.API/Models/Requests/CreateAppointmentRequest.cs +++ b/services/booking-service-net/src/BookingService.API/Models/Requests/CreateAppointmentRequest.cs @@ -12,4 +12,5 @@ public record CreateAppointmentRequest public Guid? CustomerId { get; init; } public Guid? StaffId { get; init; } public Guid? ResourceId { get; init; } + public string? Notes { get; init; } } diff --git a/services/booking-service-net/src/BookingService.API/Models/Requests/CreateTherapistRequest.cs b/services/booking-service-net/src/BookingService.API/Models/Requests/CreateTherapistRequest.cs new file mode 100644 index 00000000..58bc371f --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Models/Requests/CreateTherapistRequest.cs @@ -0,0 +1,18 @@ +// EN: Request model for creating a therapist. +// VI: Request model để tạo chuyên viên. + +using BookingService.API.Application.DTOs; + +namespace BookingService.API.Models.Requests; + +/// +/// EN: Request model for creating a new therapist. +/// VI: Request model để tạo chuyên viên mới. +/// +public record CreateTherapistRequest +{ + public Guid ShopId { get; init; } + public string Name { get; init; } = null!; + public string[] Specialties { get; init; } = []; + public WorkingHoursDto WorkingHours { get; init; } = null!; +} diff --git a/services/booking-service-net/src/BookingService.API/Models/Requests/UpdateTherapistRequest.cs b/services/booking-service-net/src/BookingService.API/Models/Requests/UpdateTherapistRequest.cs new file mode 100644 index 00000000..e54d9e7d --- /dev/null +++ b/services/booking-service-net/src/BookingService.API/Models/Requests/UpdateTherapistRequest.cs @@ -0,0 +1,17 @@ +// EN: Request model for updating a therapist. +// VI: Request model để cập nhật chuyên viên. + +using BookingService.API.Application.DTOs; + +namespace BookingService.API.Models.Requests; + +/// +/// EN: Request model for updating an existing therapist. +/// VI: Request model để cập nhật chuyên viên hiện có. +/// +public record UpdateTherapistRequest +{ + public string Name { get; init; } = null!; + public string[] Specialties { get; init; } = []; + public WorkingHoursDto WorkingHours { get; init; } = null!; +} diff --git a/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/Appointment.cs b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/Appointment.cs index 1af55f70..e51dbca8 100644 --- a/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/Appointment.cs +++ b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/Appointment.cs @@ -7,6 +7,10 @@ using BookingService.Domain.SeedWork; namespace BookingService.Domain.AggregatesModel.AppointmentAggregate; +/// +/// EN: Appointment aggregate root - represents a service booking. +/// VI: Aggregate root Appointment - đại diện cho đặt lịch dịch vụ. +/// /// /// EN: Appointment aggregate root - represents a service booking. /// VI: Aggregate root Appointment - đại diện cho đặt lịch dịch vụ. @@ -20,23 +24,82 @@ public class Appointment : Entity, IAggregateRoot private Guid _serviceId; private DateTime _startTime; private DateTime _endTime; - private string _status = null!; // Scheduled, Confirmed, InProgress, Completed, Cancelled + private string _status = null!; // Pending, Confirmed, InProgress, Completed, Cancelled, NoShow + private string? _notes; private DateTime _createdAt; + /// + /// EN: The shop this appointment belongs to. + /// VI: Shop mà cuộc hẹn này thuộc về. + /// public Guid ShopId => _shopId; + + /// + /// EN: The customer who booked this appointment. + /// VI: Khách hàng đã đặt cuộc hẹn này. + /// public Guid? CustomerId => _customerId; + + /// + /// EN: The staff/therapist assigned to this appointment. + /// VI: Nhân viên/chuyên viên được gán cho cuộc hẹn này. + /// public Guid? StaffId => _staffId; + + /// + /// EN: The resource (room/bed) assigned to this appointment. + /// VI: Tài nguyên (phòng/giường) được gán cho cuộc hẹn này. + /// public Guid? ResourceId => _resourceId; + + /// + /// EN: The service being booked. + /// VI: Dịch vụ được đặt. + /// public Guid ServiceId => _serviceId; + + /// + /// EN: Appointment start time. + /// VI: Thời gian bắt đầu cuộc hẹn. + /// public DateTime StartTime => _startTime; + + /// + /// EN: Appointment end time. + /// VI: Thời gian kết thúc cuộc hẹn. + /// public DateTime EndTime => _endTime; + + /// + /// EN: Current status of the appointment. + /// VI: Trạng thái hiện tại của cuộc hẹn. + /// public string Status => _status; + + /// + /// EN: Optional notes for the appointment. + /// VI: Ghi chú tùy chọn cho cuộc hẹn. + /// + public string? Notes => _notes; + + /// + /// EN: When the appointment was created. + /// VI: Thời điểm tạo cuộc hẹn. + /// public DateTime CreatedAt => _createdAt; + /// + /// EN: EF Core parameterless constructor. + /// VI: Constructor không tham số cho EF Core. + /// protected Appointment() { } + /// + /// EN: Create a new appointment. + /// VI: Tạo cuộc hẹn mới. + /// public Appointment( Guid shopId, Guid serviceId, @@ -44,14 +107,15 @@ public class Appointment : Entity, IAggregateRoot DateTime endTime, Guid? customerId = null, Guid? staffId = null, - Guid? resourceId = null) + Guid? resourceId = null, + string? notes = null) { if (shopId == Guid.Empty) - throw new DomainException("Shop ID cannot be empty"); + throw new DomainException("Shop ID cannot be empty / Shop ID không được trống"); if (serviceId == Guid.Empty) - throw new DomainException("Service ID cannot be empty"); + throw new DomainException("Service ID cannot be empty / Service ID không được trống"); if (endTime <= startTime) - throw new DomainException("End time must be after start time"); + throw new DomainException("End time must be after start time / Thời gian kết thúc phải sau thời gian bắt đầu"); Id = Guid.NewGuid(); _shopId = shopId; @@ -61,43 +125,76 @@ public class Appointment : Entity, IAggregateRoot _customerId = customerId; _staffId = staffId; _resourceId = resourceId; - _status = "Scheduled"; + _notes = notes; + _status = "Pending"; _createdAt = DateTime.UtcNow; AddDomainEvent(new AppointmentCreatedDomainEvent(this)); } + /// + /// EN: Confirm the appointment. + /// VI: Xác nhận cuộc hẹn. + /// public void Confirm() { - if (_status != "Scheduled") - throw new DomainException($"Cannot confirm appointment with status {_status}"); + if (_status != "Pending") + throw new DomainException($"Cannot confirm appointment with status {_status} / Không thể xác nhận cuộc hẹn có trạng thái {_status}"); _status = "Confirmed"; + AddDomainEvent(new AppointmentConfirmedDomainEvent(this)); } + /// + /// EN: Start the appointment (mark as in progress). + /// VI: Bắt đầu cuộc hẹn (đánh dấu đang thực hiện). + /// public void MarkAsInProgress() { if (_status != "Confirmed") - throw new DomainException($"Cannot start appointment with status {_status}"); + throw new DomainException($"Cannot start appointment with status {_status} / Không thể bắt đầu cuộc hẹn có trạng thái {_status}"); _status = "InProgress"; } + /// + /// EN: Complete the appointment. + /// VI: Hoàn thành cuộc hẹn. + /// public void Complete() { if (_status != "InProgress") - throw new DomainException($"Cannot complete appointment with status {_status}"); + throw new DomainException($"Cannot complete appointment with status {_status} / Không thể hoàn thành cuộc hẹn có trạng thái {_status}"); _status = "Completed"; AddDomainEvent(new AppointmentCompletedDomainEvent(this)); } + /// + /// EN: Cancel the appointment with a reason. + /// VI: Hủy cuộc hẹn với lý do. + /// + /// EN: Cancellation reason / VI: Lý do hủy public void Cancel(string reason) { if (_status == "Completed") - throw new DomainException("Cannot cancel completed appointment"); + throw new DomainException("Cannot cancel completed appointment / Không thể hủy cuộc hẹn đã hoàn thành"); + if (_status == "Cancelled") + throw new DomainException("Appointment is already cancelled / Cuộc hẹn đã bị hủy rồi"); _status = "Cancelled"; AddDomainEvent(new AppointmentCancelledDomainEvent(this, reason)); } + + /// + /// EN: Mark the appointment as no-show (customer didn't arrive). + /// VI: Đánh dấu cuộc hẹn là không đến (khách không đến). + /// + public void MarkNoShow() + { + if (_status != "Confirmed" && _status != "Pending") + throw new DomainException($"Cannot mark no-show for appointment with status {_status} / Không thể đánh dấu không đến cho cuộc hẹn có trạng thái {_status}"); + + _status = "NoShow"; + } } diff --git a/services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/ITherapistRepository.cs b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/ITherapistRepository.cs new file mode 100644 index 00000000..1a0097a5 --- /dev/null +++ b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/ITherapistRepository.cs @@ -0,0 +1,37 @@ +// EN: Repository interface for Therapist aggregate. +// VI: Interface repository cho aggregate Therapist. + +using BookingService.Domain.SeedWork; + +namespace BookingService.Domain.AggregatesModel.TherapistAggregate; + +/// +/// EN: Repository interface for managing Therapist aggregate persistence. +/// VI: Interface repository để quản lý persistence của aggregate Therapist. +/// +public interface ITherapistRepository : IRepository +{ + /// + /// EN: Add a new therapist. + /// VI: Thêm chuyên viên mới. + /// + Therapist Add(Therapist therapist); + + /// + /// EN: Update an existing therapist. + /// VI: Cập nhật chuyên viên hiện có. + /// + void Update(Therapist therapist); + + /// + /// EN: Get therapist by ID. + /// VI: Lấy chuyên viên theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get all therapists for a shop. + /// VI: Lấy tất cả chuyên viên cho một shop. + /// + Task> GetByShopIdAsync(Guid shopId, bool? isActive = null, CancellationToken cancellationToken = default); +} diff --git a/services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/Therapist.cs b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/Therapist.cs new file mode 100644 index 00000000..29dedbd4 --- /dev/null +++ b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/TherapistAggregate/Therapist.cs @@ -0,0 +1,191 @@ +// EN: Therapist aggregate root for Spa/Beauty therapist management. +// VI: Aggregate root Therapist cho quản lý chuyên viên Spa/Beauty. + +using BookingService.Domain.Events; +using BookingService.Domain.Exceptions; +using BookingService.Domain.SeedWork; + +namespace BookingService.Domain.AggregatesModel.TherapistAggregate; + +/// +/// EN: Therapist aggregate root - represents a spa/beauty therapist with specialties and working hours. +/// VI: Aggregate root Therapist - đại diện cho chuyên viên spa/beauty với chuyên môn và giờ làm việc. +/// +public class Therapist : Entity, IAggregateRoot +{ + private Guid _shopId; + private string _name = null!; + private string[] _specialties = []; + private string _workingHours = null!; // EN: JSON string for weekly schedule / VI: Chuỗi JSON cho lịch tuần + private bool _isActive; + private DateTime _createdAt; + private DateTime? _updatedAt; + + /// + /// EN: The shop this therapist belongs to. + /// VI: Shop mà chuyên viên này thuộc về. + /// + public Guid ShopId => _shopId; + + /// + /// EN: Full name of the therapist. + /// VI: Tên đầy đủ của chuyên viên. + /// + public string Name => _name; + + /// + /// EN: List of specialties (e.g., massage, facial, nails). + /// VI: Danh sách chuyên môn (ví dụ: massage, facial, nails). + /// + public string[] Specialties => _specialties; + + /// + /// EN: JSON working hours schedule for the week. + /// VI: Lịch làm việc dạng JSON cho tuần. + /// + public string WorkingHours => _workingHours; + + /// + /// EN: Whether the therapist is currently active. + /// VI: Chuyên viên có đang hoạt động hay không. + /// + public bool IsActive => _isActive; + + /// + /// EN: When the therapist record was created. + /// VI: Thời điểm tạo bản ghi chuyên viên. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: When the therapist record was last updated. + /// VI: Thời điểm cập nhật bản ghi chuyên viên lần cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: EF Core parameterless constructor. + /// VI: Constructor không tham số cho EF Core. + /// + protected Therapist() + { + } + + /// + /// EN: Create a new therapist. + /// VI: Tạo chuyên viên mới. + /// + /// EN: The shop ID / VI: ID shop + /// EN: Therapist name / VI: Tên chuyên viên + /// EN: List of specialties / VI: Danh sách chuyên môn + /// EN: JSON working hours / VI: Giờ làm việc dạng JSON + public Therapist( + Guid shopId, + string name, + string[] specialties, + string workingHours) + { + if (shopId == Guid.Empty) + throw new DomainException("Shop ID cannot be empty / Shop ID không được trống"); + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Therapist name cannot be empty / Tên chuyên viên không được trống"); + if (string.IsNullOrWhiteSpace(workingHours)) + throw new DomainException("Working hours cannot be empty / Giờ làm việc không được trống"); + + Id = Guid.NewGuid(); + _shopId = shopId; + _name = name.Trim(); + _specialties = specialties ?? []; + _workingHours = workingHours; + _isActive = true; + _createdAt = DateTime.UtcNow; + + AddDomainEvent(new TherapistCreatedDomainEvent(this)); + } + + /// + /// EN: Activate the therapist. + /// VI: Kích hoạt chuyên viên. + /// + public void Activate() + { + if (_isActive) + throw new DomainException("Therapist is already active / Chuyên viên đã đang hoạt động"); + + _isActive = true; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Deactivate the therapist. + /// VI: Vô hiệu hóa chuyên viên. + /// + public void Deactivate() + { + if (!_isActive) + throw new DomainException("Therapist is already inactive / Chuyên viên đã đang bị vô hiệu"); + + _isActive = false; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update therapist specialties. + /// VI: Cập nhật chuyên môn chuyên viên. + /// + /// EN: New specialties list / VI: Danh sách chuyên môn mới + public void UpdateSpecialties(string[] specialties) + { + _specialties = specialties ?? []; + _updatedAt = DateTime.UtcNow; + AddDomainEvent(new TherapistUpdatedDomainEvent(this)); + } + + /// + /// EN: Update therapist working hours. + /// VI: Cập nhật giờ làm việc chuyên viên. + /// + /// EN: New JSON working hours / VI: Giờ làm việc mới dạng JSON + public void UpdateWorkingHours(string workingHours) + { + if (string.IsNullOrWhiteSpace(workingHours)) + throw new DomainException("Working hours cannot be empty / Giờ làm việc không được trống"); + + _workingHours = workingHours; + _updatedAt = DateTime.UtcNow; + AddDomainEvent(new TherapistUpdatedDomainEvent(this)); + } + + /// + /// EN: Update therapist name. + /// VI: Cập nhật tên chuyên viên. + /// + /// EN: New name / VI: Tên mới + public void UpdateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Therapist name cannot be empty / Tên chuyên viên không được trống"); + + _name = name.Trim(); + _updatedAt = DateTime.UtcNow; + AddDomainEvent(new TherapistUpdatedDomainEvent(this)); + } + + /// + /// EN: Update therapist details (name, specialties, working hours). + /// VI: Cập nhật thông tin chuyên viên (tên, chuyên môn, giờ làm việc). + /// + public void Update(string name, string[] specialties, string workingHours) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Therapist name cannot be empty / Tên chuyên viên không được trống"); + if (string.IsNullOrWhiteSpace(workingHours)) + throw new DomainException("Working hours cannot be empty / Giờ làm việc không được trống"); + + _name = name.Trim(); + _specialties = specialties ?? []; + _workingHours = workingHours; + _updatedAt = DateTime.UtcNow; + AddDomainEvent(new TherapistUpdatedDomainEvent(this)); + } +} diff --git a/services/booking-service-net/src/BookingService.Domain/Events/BookingDomainEvents.cs b/services/booking-service-net/src/BookingService.Domain/Events/BookingDomainEvents.cs index eb8719b1..2b11e470 100644 --- a/services/booking-service-net/src/BookingService.Domain/Events/BookingDomainEvents.cs +++ b/services/booking-service-net/src/BookingService.Domain/Events/BookingDomainEvents.cs @@ -2,16 +2,25 @@ // VI: Domain events của Booking. using BookingService.Domain.AggregatesModel.AppointmentAggregate; +using BookingService.Domain.AggregatesModel.TherapistAggregate; using MediatR; namespace BookingService.Domain.Events; +// ==================== Appointment Events ==================== + /// /// EN: Domain event raised when an appointment is created. /// VI: Domain event phát ra khi lịch hẹn được tạo. /// public record AppointmentCreatedDomainEvent(Appointment Appointment) : INotification; +/// +/// EN: Domain event raised when an appointment is confirmed. +/// VI: Domain event phát ra khi lịch hẹn được xác nhận. +/// +public record AppointmentConfirmedDomainEvent(Appointment Appointment) : INotification; + /// /// EN: Domain event raised when an appointment is completed. /// VI: Domain event phát ra khi lịch hẹn hoàn thành. @@ -23,3 +32,17 @@ public record AppointmentCompletedDomainEvent(Appointment Appointment) : INotifi /// VI: Domain event phát ra khi lịch hẹn bị hủy. /// public record AppointmentCancelledDomainEvent(Appointment Appointment, string Reason) : INotification; + +// ==================== Therapist Events ==================== + +/// +/// EN: Domain event raised when a therapist is created. +/// VI: Domain event phát ra khi chuyên viên được tạo. +/// +public record TherapistCreatedDomainEvent(Therapist Therapist) : INotification; + +/// +/// EN: Domain event raised when a therapist is updated. +/// VI: Domain event phát ra khi chuyên viên được cập nhật. +/// +public record TherapistUpdatedDomainEvent(Therapist Therapist) : INotification; diff --git a/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs b/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs index 6f2df09a..5c1f06ec 100644 --- a/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs +++ b/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Storage; using BookingService.Domain.AggregatesModel.AppointmentAggregate; using BookingService.Domain.AggregatesModel.ResourceAggregate; using BookingService.Domain.AggregatesModel.StaffAggregate; +using BookingService.Domain.AggregatesModel.TherapistAggregate; using BookingService.Domain.SeedWork; using BookingService.Infrastructure.EntityConfigurations; @@ -17,6 +18,7 @@ public class BookingContext : DbContext, IUnitOfWork public DbSet Appointments => Set(); public DbSet Resources => Set(); public DbSet StaffSchedules => Set(); + public DbSet Therapists => Set(); public IDbContextTransaction? CurrentTransaction => _currentTransaction; public bool HasActiveTransaction => _currentTransaction != null; @@ -31,6 +33,7 @@ public class BookingContext : DbContext, IUnitOfWork modelBuilder.ApplyConfiguration(new AppointmentStatusEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new ResourceEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new StaffScheduleEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new TherapistEntityTypeConfiguration()); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) diff --git a/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs b/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs index ad6b5f15..e699b361 100644 --- a/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs +++ b/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using BookingService.Domain.AggregatesModel.AppointmentAggregate; +using BookingService.Domain.AggregatesModel.TherapistAggregate; using BookingService.Infrastructure.Idempotency; using BookingService.Infrastructure.Repositories; @@ -50,6 +51,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); diff --git a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/AppointmentEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/AppointmentEntityTypeConfiguration.cs index 866bd204..19c8e270 100644 --- a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/AppointmentEntityTypeConfiguration.cs +++ b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/AppointmentEntityTypeConfiguration.cs @@ -60,6 +60,11 @@ public class AppointmentEntityTypeConfiguration : IEntityTypeConfiguration a.Notes) + .HasField("_notes") + .HasColumnName("notes") + .HasMaxLength(1000); + builder.Property(a => a.CreatedAt) .HasField("_createdAt") .HasColumnName("created_at") diff --git a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/TherapistEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/TherapistEntityTypeConfiguration.cs new file mode 100644 index 00000000..41801c3c --- /dev/null +++ b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/TherapistEntityTypeConfiguration.cs @@ -0,0 +1,71 @@ +// EN: Entity type configuration for Therapist. +// VI: Cấu hình entity type cho Therapist. + +using BookingService.Domain.AggregatesModel.TherapistAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BookingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity type configuration for Therapist aggregate root. +/// VI: Cấu hình entity type cho aggregate root Therapist. +/// +public class TherapistEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("therapists"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(t => t.ShopId) + .HasField("_shopId") + .HasColumnName("shop_id") + .IsRequired(); + + builder.Property(t => t.Name) + .HasField("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(t => t.Specialties) + .HasField("_specialties") + .HasColumnName("specialties") + .HasColumnType("text[]"); + + builder.Property(t => t.WorkingHours) + .HasField("_workingHours") + .HasColumnName("working_hours") + .HasColumnType("jsonb") + .IsRequired(); + + builder.Property(t => t.IsActive) + .HasField("_isActive") + .HasColumnName("is_active") + .IsRequired(); + + builder.Property(t => t.CreatedAt) + .HasField("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(t => t.UpdatedAt) + .HasField("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Ignore domain events from persistence / VI: Bỏ qua domain events khi persist + builder.Ignore(t => t.DomainEvents); + + // EN: Indexes / VI: Indexes + builder.HasIndex(t => t.ShopId) + .HasDatabaseName("ix_therapists_shop_id"); + + builder.HasIndex(t => new { t.ShopId, t.IsActive }) + .HasDatabaseName("ix_therapists_shop_active"); + } +} diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Repositories/TherapistRepository.cs b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/TherapistRepository.cs new file mode 100644 index 00000000..3f45f065 --- /dev/null +++ b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/TherapistRepository.cs @@ -0,0 +1,70 @@ +// EN: Repository implementation for Therapist aggregate. +// VI: Implementation repository cho aggregate Therapist. + +using BookingService.Domain.AggregatesModel.TherapistAggregate; +using BookingService.Domain.SeedWork; +using Microsoft.EntityFrameworkCore; + +namespace BookingService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for managing Therapist aggregate persistence. +/// VI: Implementation repository để quản lý persistence của aggregate Therapist. +/// +public class TherapistRepository : ITherapistRepository +{ + private readonly BookingContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public TherapistRepository(BookingContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// EN: Add a new therapist to the context. + /// VI: Thêm chuyên viên mới vào context. + /// + public Therapist Add(Therapist therapist) + { + return _context.Therapists.Add(therapist).Entity; + } + + /// + /// EN: Update an existing therapist in the context. + /// VI: Cập nhật chuyên viên hiện có trong context. + /// + public void Update(Therapist therapist) + { + _context.Entry(therapist).State = EntityState.Modified; + } + + /// + /// EN: Get a therapist by ID. + /// VI: Lấy chuyên viên theo ID. + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Therapists + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + /// + /// EN: Get all therapists for a shop, optionally filtered by active status. + /// VI: Lấy tất cả chuyên viên cho một shop, tùy chọn lọc theo trạng thái hoạt động. + /// + public async Task> GetByShopIdAsync(Guid shopId, bool? isActive = null, CancellationToken cancellationToken = default) + { + var query = _context.Therapists.Where(t => t.ShopId == shopId); + + if (isActive.HasValue) + { + query = query.Where(t => t.IsActive == isActive.Value); + } + + return await query + .OrderBy(t => t.Name) + .ToListAsync(cancellationToken); + } +} diff --git a/services/booking-service-net/tests/BookingService.UnitTests/Domain/AppointmentAggregateTests.cs b/services/booking-service-net/tests/BookingService.UnitTests/Domain/AppointmentAggregateTests.cs index 8fdb95c9..672a2df5 100644 --- a/services/booking-service-net/tests/BookingService.UnitTests/Domain/AppointmentAggregateTests.cs +++ b/services/booking-service-net/tests/BookingService.UnitTests/Domain/AppointmentAggregateTests.cs @@ -23,7 +23,7 @@ public class AppointmentAggregateTests Guid.NewGuid()); // Assert - appointment.Status.Should().Be("Scheduled"); + appointment.Status.Should().Be("Pending"); appointment.Id.Should().NotBeEmpty(); } diff --git a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs index 092997b3..4cd68678 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs @@ -63,6 +63,12 @@ public record ProductDto /// public string? Sku { get; init; } + /// + /// EN: Barcode value (EAN-13, UPC, etc.) for POS scanner. + /// VI: Giá trị barcode (EAN-13, UPC, v.v.) cho máy quét POS. + /// + public string? Barcode { get; init; } + /// /// EN: Category ID. /// VI: ID danh mục. diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQuery.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQuery.cs new file mode 100644 index 00000000..02bb2d46 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQuery.cs @@ -0,0 +1,13 @@ +// EN: Query to get a product by barcode or SKU for POS scanner lookup. +// VI: Query để lấy sản phẩm theo barcode hoặc SKU cho tra cứu máy quét POS. + +using MediatR; +using CatalogService.API.Application.DTOs; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Query to find a product by barcode or SKU within a shop (for POS scanner). +/// VI: Query để tìm sản phẩm theo barcode hoặc SKU trong shop (cho máy quét POS). +/// +public record GetProductByBarcodeQuery(Guid ShopId, string Barcode) : IRequest; diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQueryHandler.cs new file mode 100644 index 00000000..244c4ecc --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByBarcodeQueryHandler.cs @@ -0,0 +1,70 @@ +// EN: Handler for GetProductByBarcodeQuery. +// VI: Handler cho GetProductByBarcodeQuery. + +using System.Text.Json; +using MediatR; +using CatalogService.API.Application.DTOs; +using CatalogService.Domain.AggregatesModel.ProductAggregate; +using CatalogService.Domain.SeedWork; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Handler for barcode/SKU product lookup (used by POS scanner). +/// VI: Handler tra cứu sản phẩm theo barcode/SKU (dùng cho máy quét POS). +/// +public class GetProductByBarcodeQueryHandler : IRequestHandler +{ + private readonly IProductRepository _repository; + private readonly ILogger _logger; + + public GetProductByBarcodeQueryHandler( + IProductRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(GetProductByBarcodeQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Looking up product by barcode/SKU {Code} in shop {ShopId} / VI: Tra cứu sản phẩm theo barcode/SKU {Code} trong shop {ShopId}", + request.Barcode, request.ShopId); + + var product = await _repository.GetByBarcodeOrSkuAsync( + request.ShopId, request.Barcode, cancellationToken); + + if (product == null) + { + _logger.LogWarning( + "EN: Product not found for barcode/SKU {Code} in shop {ShopId} / VI: Không tìm thấy sản phẩm cho barcode/SKU {Code} trong shop {ShopId}", + request.Barcode, request.ShopId); + return null; + } + + // EN: Resolve type name from enumeration + // VI: Resolve tên loại từ enumeration + var typeMap = Enumeration.GetAll().ToDictionary(t => t.Id, t => t.Name); + + return new ProductDto + { + Id = product.Id, + ShopId = product.ShopId, + Name = product.Name, + Description = product.Description, + Price = product.Price, + Type = typeMap.GetValueOrDefault(product.TypeId, "Unknown"), + Attributes = product.Attributes != null + ? JsonSerializer.Deserialize>(product.Attributes.RootElement.GetRawText()) + : null, + ImageUrl = product.ImageUrl, + Sku = product.Sku, + Barcode = product.Barcode, + CategoryId = product.CategoryId, + IsActive = product.IsActive, + CreatedAt = product.CreatedAt, + UpdatedAt = product.UpdatedAt + }; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs index 724f0b2a..4210cb91 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs @@ -55,6 +55,7 @@ public class GetProductByIdQueryHandler : IRequestHandler + /// EN: Lookup product by barcode or SKU (for POS scanner). + /// VI: Tra cứu sản phẩm theo barcode hoặc SKU (cho máy quét POS). + /// + [HttpGet("lookup")] + [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> LookupProduct( + [FromQuery] Guid shopId, + [FromQuery] string barcode, + CancellationToken cancellationToken = default) + { + if (shopId == Guid.Empty || string.IsNullOrWhiteSpace(barcode)) + { + return BadRequest(new { success = false, error = new { code = "INVALID_REQUEST", message = "Shop ID and barcode are required / Shop ID và barcode là bắt buộc" } }); + } + + var query = new GetProductByBarcodeQuery(shopId, barcode.Trim()); + var result = await _mediator.Send(query, cancellationToken); + + if (result == null) + { + return NotFound(new { success = false, error = new { code = "PRODUCT_NOT_FOUND", message = $"No product found for barcode/SKU '{barcode}' / Không tìm thấy sản phẩm cho barcode/SKU '{barcode}'" } }); + } + + return Ok(new { success = true, data = result }); + } + /// /// EN: Get product by ID. /// VI: Lấy sản phẩm theo ID. diff --git a/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/IProductRepository.cs b/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/IProductRepository.cs index 63c14e49..9fdd9f23 100644 --- a/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/IProductRepository.cs +++ b/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/IProductRepository.cs @@ -40,4 +40,10 @@ public interface IProductRepository : IRepository /// VI: Lấy danh sách sản phẩm theo loại. /// Task> GetByTypeAsync(Guid shopId, ProductType type, CancellationToken cancellationToken = default); + + /// + /// EN: Get product by barcode or SKU for POS scanner lookup. + /// VI: Lấy sản phẩm theo barcode hoặc SKU cho tra cứu máy quét POS. + /// + Task GetByBarcodeOrSkuAsync(Guid shopId, string code, CancellationToken cancellationToken = default); } diff --git a/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/Product.cs b/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/Product.cs index 0593c22e..f9899b77 100644 --- a/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/Product.cs +++ b/services/catalog-service-net/src/CatalogService.Domain/AggregatesModel/ProductAggregate/Product.cs @@ -21,6 +21,7 @@ public class Product : Entity, IAggregateRoot private JsonDocument? _attributes; // Type-specific attributes in JSONB private string? _imageUrl; private string? _sku; + private string? _barcode; private Guid? _categoryId; private bool _isActive; private DateTime _createdAt; @@ -74,6 +75,12 @@ public class Product : Entity, IAggregateRoot /// public string? Sku => _sku; + /// + /// EN: Barcode value (EAN-13, UPC, etc.) for POS scanner lookup. + /// VI: Giá trị barcode (EAN-13, UPC, v.v.) cho tra cứu máy quét POS. + /// + public string? Barcode => _barcode; + /// /// EN: Category ID for product classification. /// VI: ID danh mục để phân loại sản phẩm. @@ -118,7 +125,8 @@ public class Product : Entity, IAggregateRoot string? description = null, JsonDocument? attributes = null, string? sku = null, - Guid? categoryId = null) + Guid? categoryId = null, + string? barcode = null) { if (shopId == Guid.Empty) throw new DomainException("Shop ID cannot be empty"); @@ -136,6 +144,7 @@ public class Product : Entity, IAggregateRoot TypeId = type.Id; _attributes = attributes; _sku = sku?.Trim(); + _barcode = barcode?.Trim(); _categoryId = categoryId; _isActive = true; _createdAt = DateTime.UtcNow; @@ -203,6 +212,26 @@ public class Product : Entity, IAggregateRoot _updatedAt = DateTime.UtcNow; } + /// + /// EN: Update product barcode for POS scanner lookup. + /// VI: Cập nhật barcode sản phẩm cho tra cứu máy quét POS. + /// + public void UpdateBarcode(string? barcode) + { + _barcode = barcode?.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update product SKU. + /// VI: Cập nhật mã SKU sản phẩm. + /// + public void UpdateSku(string? sku) + { + _sku = sku?.Trim(); + _updatedAt = DateTime.UtcNow; + } + /// /// EN: Deactivate product. /// VI: Vô hiệu hóa sản phẩm. diff --git a/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs b/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs index 4256e7a6..72fd88be 100644 --- a/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs +++ b/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs @@ -77,6 +77,11 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration .HasColumnName("sku") .HasMaxLength(100); + builder.Property(p => p.Barcode) + .HasField("_barcode") + .HasColumnName("barcode") + .HasMaxLength(100); + builder.Property(p => p.CategoryId) .HasField("_categoryId") .HasColumnName("category_id"); @@ -100,6 +105,7 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration builder.HasIndex(p => p.ShopId).HasDatabaseName("ix_products_shop_id"); builder.HasIndex(p => p.TypeId).HasDatabaseName("ix_products_type_id"); builder.HasIndex(p => p.Sku).HasDatabaseName("ix_products_sku"); + builder.HasIndex(p => p.Barcode).HasDatabaseName("ix_products_barcode"); builder.HasIndex(p => p.IsActive).HasDatabaseName("ix_products_is_active"); builder.HasIndex(p => p.CategoryId).HasDatabaseName("ix_products_category_id"); diff --git a/services/catalog-service-net/src/CatalogService.Infrastructure/Repositories/ProductRepository.cs b/services/catalog-service-net/src/CatalogService.Infrastructure/Repositories/ProductRepository.cs index 6af80cad..47d40de1 100644 --- a/services/catalog-service-net/src/CatalogService.Infrastructure/Repositories/ProductRepository.cs +++ b/services/catalog-service-net/src/CatalogService.Infrastructure/Repositories/ProductRepository.cs @@ -51,4 +51,16 @@ public class ProductRepository : IProductRepository .Where(p => p.ShopId == shopId && p.TypeId == type.Id) .ToListAsync(cancellationToken); } + + /// + /// EN: Get product by barcode or SKU for POS scanner lookup. + /// VI: Lấy sản phẩm theo barcode hoặc SKU cho tra cứu máy quét POS. + /// + public async Task GetByBarcodeOrSkuAsync(Guid shopId, string code, CancellationToken cancellationToken = default) + { + return await _context.Products + .Where(p => p.ShopId == shopId && p.IsActive + && (p.Barcode == code || p.Sku == code)) + .FirstOrDefaultAsync(cancellationToken); + } } diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommand.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommand.cs new file mode 100644 index 00000000..1e26c77c --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommand.cs @@ -0,0 +1,12 @@ +// EN: Command to cancel a barista queue item. +// VI: Command de huy item trong hang doi barista. + +using MediatR; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Command to cancel a drink in the barista queue. +/// VI: Command de huy do uong trong hang doi barista. +/// +public record CancelQueueItemCommand(Guid QueueItemId) : IRequest; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommandHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommandHandler.cs new file mode 100644 index 00000000..c4cf5d29 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CancelQueueItemCommandHandler.cs @@ -0,0 +1,42 @@ +// EN: Handler for CancelQueueItemCommand. +// VI: Handler cho CancelQueueItemCommand. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Handler for cancelling a barista queue item. +/// VI: Handler de huy item trong hang doi barista. +/// +public class CancelQueueItemCommandHandler : IRequestHandler +{ + private readonly IBaristaQueueRepository _repository; + private readonly ILogger _logger; + + public CancelQueueItemCommandHandler( + IBaristaQueueRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CancelQueueItemCommand request, CancellationToken cancellationToken) + { + var item = await _repository.GetByIdAsync(request.QueueItemId, cancellationToken); + if (item == null) + throw new KeyNotFoundException( + $"Queue item {request.QueueItemId} not found / Item hang doi {request.QueueItemId} khong tim thay"); + + item.Cancel(); + _repository.Update(item); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Drink {DrinkName} (ID: {QueueItemId}) cancelled", + item.DrinkName, item.Id); + + return true; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommand.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommand.cs new file mode 100644 index 00000000..0abdac90 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommand.cs @@ -0,0 +1,12 @@ +// EN: Command to mark a drink as delivered to the customer. +// VI: Command de danh dau do uong da giao cho khach. + +using MediatR; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Command to mark a drink as delivered. +/// VI: Command de danh dau do uong da giao. +/// +public record MarkDrinkDeliveredCommand(Guid QueueItemId) : IRequest; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommandHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommandHandler.cs new file mode 100644 index 00000000..cbe0df43 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkDeliveredCommandHandler.cs @@ -0,0 +1,42 @@ +// EN: Handler for MarkDrinkDeliveredCommand. +// VI: Handler cho MarkDrinkDeliveredCommand. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Handler for marking a drink as delivered. +/// VI: Handler de danh dau do uong da giao. +/// +public class MarkDrinkDeliveredCommandHandler : IRequestHandler +{ + private readonly IBaristaQueueRepository _repository; + private readonly ILogger _logger; + + public MarkDrinkDeliveredCommandHandler( + IBaristaQueueRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(MarkDrinkDeliveredCommand request, CancellationToken cancellationToken) + { + var item = await _repository.GetByIdAsync(request.QueueItemId, cancellationToken); + if (item == null) + throw new KeyNotFoundException( + $"Queue item {request.QueueItemId} not found / Item hang doi {request.QueueItemId} khong tim thay"); + + item.MarkDelivered(); + _repository.Update(item); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Drink {DrinkName} (ID: {QueueItemId}) delivered", + item.DrinkName, item.Id); + + return true; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommand.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommand.cs new file mode 100644 index 00000000..fb26742a --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommand.cs @@ -0,0 +1,12 @@ +// EN: Command to mark a drink as ready for pickup. +// VI: Command de danh dau do uong san sang de lay. + +using MediatR; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Command to mark a drink as ready for pickup. +/// VI: Command de danh dau do uong san sang de lay. +/// +public record MarkDrinkReadyCommand(Guid QueueItemId) : IRequest; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommandHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommandHandler.cs new file mode 100644 index 00000000..8186a3fd --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/MarkDrinkReadyCommandHandler.cs @@ -0,0 +1,42 @@ +// EN: Handler for MarkDrinkReadyCommand. +// VI: Handler cho MarkDrinkReadyCommand. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Handler for marking a drink as ready. +/// VI: Handler de danh dau do uong san sang. +/// +public class MarkDrinkReadyCommandHandler : IRequestHandler +{ + private readonly IBaristaQueueRepository _repository; + private readonly ILogger _logger; + + public MarkDrinkReadyCommandHandler( + IBaristaQueueRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(MarkDrinkReadyCommand request, CancellationToken cancellationToken) + { + var item = await _repository.GetByIdAsync(request.QueueItemId, cancellationToken); + if (item == null) + throw new KeyNotFoundException( + $"Queue item {request.QueueItemId} not found / Item hang doi {request.QueueItemId} khong tim thay"); + + item.MarkReady(); + _repository.Update(item); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Drink {DrinkName} (ID: {QueueItemId}) marked as ready", + item.DrinkName, item.Id); + + return true; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommand.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommand.cs new file mode 100644 index 00000000..63158db2 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommand.cs @@ -0,0 +1,32 @@ +// EN: Command to add a drink to the barista preparation queue. +// VI: Command de them do uong vao hang doi pha che barista. + +using MediatR; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Command to queue a drink for barista preparation. +/// VI: Command de xep hang do uong cho barista pha che. +/// +public record QueueDrinkCommand( + Guid ShopId, + Guid OrderId, + Guid OrderItemId, + string DrinkName, + string? Customizations = null, + int Priority = 0, + int EstimatedMinutes = 5 +) : IRequest; + +/// +/// EN: Result of queue drink command. +/// VI: Ket qua cua queue drink command. +/// +public record QueueDrinkResult( + Guid QueueItemId, + string DrinkName, + int Priority, + int EstimatedMinutes, + string Status +); diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommandHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommandHandler.cs new file mode 100644 index 00000000..b7d25cf6 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/QueueDrinkCommandHandler.cs @@ -0,0 +1,51 @@ +// EN: Handler for QueueDrinkCommand. +// VI: Handler cho QueueDrinkCommand. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Handler for adding a drink to the barista queue. +/// VI: Handler de them do uong vao hang doi barista. +/// +public class QueueDrinkCommandHandler : IRequestHandler +{ + private readonly IBaristaQueueRepository _repository; + private readonly ILogger _logger; + + public QueueDrinkCommandHandler( + IBaristaQueueRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(QueueDrinkCommand request, CancellationToken cancellationToken) + { + var queueItem = new BaristaQueueItem( + request.ShopId, + request.OrderId, + request.OrderItemId, + request.DrinkName, + request.Customizations, + request.Priority, + request.EstimatedMinutes); + + await _repository.AddAsync(queueItem, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Queued drink {DrinkName} (ID: {QueueItemId}) for shop {ShopId}, order {OrderId}", + request.DrinkName, queueItem.Id, request.ShopId, request.OrderId); + + return new QueueDrinkResult( + queueItem.Id, + queueItem.DrinkName, + queueItem.Priority, + queueItem.EstimatedMinutes, + queueItem.StatusName); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommand.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommand.cs new file mode 100644 index 00000000..b01ed215 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommand.cs @@ -0,0 +1,15 @@ +// EN: Command to start preparing a drink in the barista queue. +// VI: Command de bat dau pha che do uong trong hang doi barista. + +using MediatR; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Command to start preparing a queued drink. +/// VI: Command de bat dau pha che do uong dang cho. +/// +public record StartPreparingCommand( + Guid QueueItemId, + string BaristaName +) : IRequest; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommandHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommandHandler.cs new file mode 100644 index 00000000..4414784f --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/StartPreparingCommandHandler.cs @@ -0,0 +1,43 @@ +// EN: Handler for StartPreparingCommand. +// VI: Handler cho StartPreparingCommand. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Handler for starting drink preparation. +/// VI: Handler de bat dau pha che do uong. +/// +public class StartPreparingCommandHandler : IRequestHandler +{ + private readonly IBaristaQueueRepository _repository; + private readonly ILogger _logger; + + public StartPreparingCommandHandler( + IBaristaQueueRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(StartPreparingCommand request, CancellationToken cancellationToken) + { + var item = await _repository.GetByIdAsync(request.QueueItemId, cancellationToken); + if (item == null) + throw new KeyNotFoundException( + $"Queue item {request.QueueItemId} not found / Item hang doi {request.QueueItemId} khong tim thay"); + + item.StartPreparing(request.BaristaName); + _repository.Update(item); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Barista {BaristaName} started preparing {DrinkName} (ID: {QueueItemId})", + request.BaristaName, item.DrinkName, item.Id); + + return true; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQuery.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQuery.cs new file mode 100644 index 00000000..41a11653 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQuery.cs @@ -0,0 +1,33 @@ +// EN: Query to get the barista queue for a shop. +// VI: Query de lay hang doi barista cho shop. + +using MediatR; + +namespace FnbEngine.API.Application.Queries; + +/// +/// EN: Query to get active barista queue items for a shop. +/// VI: Query de lay cac item hang doi barista dang hoat dong cho shop. +/// +public record GetBaristaQueueQuery(Guid ShopId) : IRequest>; + +/// +/// EN: Barista queue item DTO. +/// VI: DTO item hang doi barista. +/// +public record BaristaQueueItemDto( + Guid Id, + Guid ShopId, + Guid OrderId, + Guid OrderItemId, + string DrinkName, + string? Customizations, + int Priority, + int StatusId, + string StatusName, + string? AssignedTo, + int EstimatedMinutes, + DateTime CreatedAt, + DateTime? StartedAt, + DateTime? CompletedAt +); diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQueryHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQueryHandler.cs new file mode 100644 index 00000000..638ea277 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetBaristaQueueQueryHandler.cs @@ -0,0 +1,43 @@ +// EN: Handler for GetBaristaQueueQuery. +// VI: Handler cho GetBaristaQueueQuery. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Queries; + +/// +/// EN: Handler for getting the barista queue for a shop. +/// VI: Handler de lay hang doi barista cho shop. +/// +public class GetBaristaQueueQueryHandler : IRequestHandler> +{ + private readonly IBaristaQueueRepository _repository; + + public GetBaristaQueueQueryHandler(IBaristaQueueRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async Task> Handle( + GetBaristaQueueQuery request, CancellationToken cancellationToken) + { + var items = await _repository.GetActiveByShopAsync(request.ShopId, cancellationToken); + + return items.Select(item => new BaristaQueueItemDto( + item.Id, + item.ShopId, + item.OrderId, + item.OrderItemId, + item.DrinkName, + item.Customizations, + item.Priority, + item.StatusId, + item.StatusName, + item.AssignedTo, + item.EstimatedMinutes, + item.CreatedAt, + item.StartedAt, + item.CompletedAt)); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQuery.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQuery.cs new file mode 100644 index 00000000..58fe6859 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQuery.cs @@ -0,0 +1,26 @@ +// EN: Query to get barista queue statistics for a shop. +// VI: Query de lay thong ke hang doi barista cho shop. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Queries; + +/// +/// EN: Query to get queue statistics for a shop. +/// VI: Query de lay thong ke hang doi cho shop. +/// +public record GetQueueStatsQuery(Guid ShopId) : IRequest; + +/// +/// EN: Queue statistics DTO. +/// VI: DTO thong ke hang doi. +/// +public record QueueStatsDto( + int TotalQueued, + int TotalPreparing, + int TotalReady, + int TotalDelivered, + int TotalCancelled, + double AveragePrepTimeMinutes +); diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQueryHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQueryHandler.cs new file mode 100644 index 00000000..90f53daa --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetQueueStatsQueryHandler.cs @@ -0,0 +1,34 @@ +// EN: Handler for GetQueueStatsQuery. +// VI: Handler cho GetQueueStatsQuery. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.API.Application.Queries; + +/// +/// EN: Handler for getting queue statistics for a shop. +/// VI: Handler de lay thong ke hang doi cho shop. +/// +public class GetQueueStatsQueryHandler : IRequestHandler +{ + private readonly IBaristaQueueRepository _repository; + + public GetQueueStatsQueryHandler(IBaristaQueueRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async Task Handle(GetQueueStatsQuery request, CancellationToken cancellationToken) + { + var stats = await _repository.GetStatsAsync(request.ShopId, cancellationToken); + + return new QueueStatsDto( + stats.TotalQueued, + stats.TotalPreparing, + stats.TotalReady, + stats.TotalDelivered, + stats.TotalCancelled, + stats.AveragePrepTimeMinutes); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/BaristaCommandValidators.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/BaristaCommandValidators.cs new file mode 100644 index 00000000..e67d6ce3 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/BaristaCommandValidators.cs @@ -0,0 +1,110 @@ +// EN: Validators for Barista queue commands. +// VI: Validators cho cac commands hang doi Barista. + +using FluentValidation; +using FnbEngine.API.Application.Commands; + +namespace FnbEngine.API.Application.Validations; + +/// +/// EN: Validator for QueueDrinkCommand. +/// VI: Validator cho QueueDrinkCommand. +/// +public class QueueDrinkCommandValidator : AbstractValidator +{ + public QueueDrinkCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + + RuleFor(x => x.OrderId) + .NotEmpty() + .WithMessage("Order ID is required / Order ID la bat buoc"); + + RuleFor(x => x.OrderItemId) + .NotEmpty() + .WithMessage("Order item ID is required / Order item ID la bat buoc"); + + RuleFor(x => x.DrinkName) + .NotEmpty() + .WithMessage("Drink name is required / Ten do uong la bat buoc") + .MaximumLength(200) + .WithMessage("Drink name must not exceed 200 characters / Ten do uong khong vuot qua 200 ky tu"); + + RuleFor(x => x.Customizations) + .MaximumLength(2000) + .WithMessage("Customizations must not exceed 2000 characters / Tuy chinh khong vuot qua 2000 ky tu") + .When(x => x.Customizations != null); + + RuleFor(x => x.Priority) + .InclusiveBetween(0, 10) + .WithMessage("Priority must be between 0 and 10 / Do uu tien phai tu 0 den 10"); + + RuleFor(x => x.EstimatedMinutes) + .InclusiveBetween(1, 60) + .WithMessage("Estimated minutes must be between 1 and 60 / Thoi gian du kien phai tu 1 den 60 phut"); + } +} + +/// +/// EN: Validator for StartPreparingCommand. +/// VI: Validator cho StartPreparingCommand. +/// +public class StartPreparingCommandValidator : AbstractValidator +{ + public StartPreparingCommandValidator() + { + RuleFor(x => x.QueueItemId) + .NotEmpty() + .WithMessage("Queue item ID is required / Queue item ID la bat buoc"); + + RuleFor(x => x.BaristaName) + .NotEmpty() + .WithMessage("Barista name is required / Ten barista la bat buoc") + .MaximumLength(100) + .WithMessage("Barista name must not exceed 100 characters / Ten barista khong vuot qua 100 ky tu"); + } +} + +/// +/// EN: Validator for MarkDrinkReadyCommand. +/// VI: Validator cho MarkDrinkReadyCommand. +/// +public class MarkDrinkReadyCommandValidator : AbstractValidator +{ + public MarkDrinkReadyCommandValidator() + { + RuleFor(x => x.QueueItemId) + .NotEmpty() + .WithMessage("Queue item ID is required / Queue item ID la bat buoc"); + } +} + +/// +/// EN: Validator for MarkDrinkDeliveredCommand. +/// VI: Validator cho MarkDrinkDeliveredCommand. +/// +public class MarkDrinkDeliveredCommandValidator : AbstractValidator +{ + public MarkDrinkDeliveredCommandValidator() + { + RuleFor(x => x.QueueItemId) + .NotEmpty() + .WithMessage("Queue item ID is required / Queue item ID la bat buoc"); + } +} + +/// +/// EN: Validator for CancelQueueItemCommand. +/// VI: Validator cho CancelQueueItemCommand. +/// +public class CancelQueueItemCommandValidator : AbstractValidator +{ + public CancelQueueItemCommandValidator() + { + RuleFor(x => x.QueueItemId) + .NotEmpty() + .WithMessage("Queue item ID is required / Queue item ID la bat buoc"); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Controllers/BaristaController.cs b/services/fnb-engine-net/src/FnbEngine.API/Controllers/BaristaController.cs new file mode 100644 index 00000000..cd3594e1 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Controllers/BaristaController.cs @@ -0,0 +1,186 @@ +// EN: Controller for barista drink preparation queue. +// VI: Controller cho hang doi pha che barista. + +using MediatR; +using Microsoft.AspNetCore.Mvc; +using FnbEngine.API.Application.Commands; +using FnbEngine.API.Application.Queries; + +namespace FnbEngine.API.Controllers; + +/// +/// EN: Controller for managing the barista drink preparation queue. +/// VI: Controller de quan ly hang doi pha che barista. +/// +[ApiController] +[Route("api/v1/fnb/barista")] +public class BaristaController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public BaristaController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get current barista queue for a shop (active items sorted by priority). + /// VI: Lay hang doi barista hien tai cho shop (item dang hoat dong sap xep theo uu tien). + /// + [HttpGet("queue")] + [ProducesResponseType(typeof(ApiResponse>), 200)] + public async Task>>> GetQueue( + [FromQuery] Guid shopId, + CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetBaristaQueueQuery(shopId), cancellationToken); + return Ok(new ApiResponse> { Success = true, Data = result }); + } + + /// + /// EN: Get queue statistics for a shop. + /// VI: Lay thong ke hang doi cho shop. + /// + [HttpGet("stats")] + [ProducesResponseType(typeof(ApiResponse), 200)] + public async Task>> GetStats( + [FromQuery] Guid shopId, + CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetQueueStatsQuery(shopId), cancellationToken); + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Add a drink to the barista queue. + /// VI: Them do uong vao hang doi barista. + /// + [HttpPost("queue")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(400)] + public async Task>> QueueDrink( + [FromBody] QueueDrinkCommand command, + CancellationToken cancellationToken) + { + var result = await _mediator.Send(command, cancellationToken); + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Start preparing a queued drink. + /// VI: Bat dau pha che do uong dang cho. + /// + [HttpPut("queue/{id}/start")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + [ProducesResponseType(400)] + public async Task>> StartPreparing( + Guid id, + [FromBody] StartPreparingRequest request, + CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send( + new StartPreparingCommand(id, request.BaristaName), cancellationToken); + return Ok(new ApiResponse { Success = true, Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new ApiResponse { Success = false, Error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new ApiResponse { Success = false, Error = ex.Message }); + } + } + + /// + /// EN: Mark a drink as ready for pickup. + /// VI: Danh dau do uong san sang de lay. + /// + [HttpPut("queue/{id}/ready")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + [ProducesResponseType(400)] + public async Task>> MarkReady( + Guid id, + CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send(new MarkDrinkReadyCommand(id), cancellationToken); + return Ok(new ApiResponse { Success = true, Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new ApiResponse { Success = false, Error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new ApiResponse { Success = false, Error = ex.Message }); + } + } + + /// + /// EN: Mark a drink as delivered to the customer. + /// VI: Danh dau do uong da giao cho khach. + /// + [HttpPut("queue/{id}/delivered")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + [ProducesResponseType(400)] + public async Task>> MarkDelivered( + Guid id, + CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send(new MarkDrinkDeliveredCommand(id), cancellationToken); + return Ok(new ApiResponse { Success = true, Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new ApiResponse { Success = false, Error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new ApiResponse { Success = false, Error = ex.Message }); + } + } + + /// + /// EN: Cancel a drink in the barista queue. + /// VI: Huy do uong trong hang doi barista. + /// + [HttpDelete("queue/{id}")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + [ProducesResponseType(400)] + public async Task>> CancelQueueItem( + Guid id, + CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send(new CancelQueueItemCommand(id), cancellationToken); + return Ok(new ApiResponse { Success = true, Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new ApiResponse { Success = false, Error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new ApiResponse { Success = false, Error = ex.Message }); + } + } +} + +/// +/// EN: Request body for starting preparation. +/// VI: Request body de bat dau pha che. +/// +public record StartPreparingRequest(string BaristaName); diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/BaristaQueueItem.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/BaristaQueueItem.cs new file mode 100644 index 00000000..bb099acb --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/BaristaQueueItem.cs @@ -0,0 +1,232 @@ +// EN: Barista queue item entity for cafe drink preparation queue. +// VI: Entity BaristaQueueItem cho hang doi pha che cafe. + +using FnbEngine.Domain.Events; +using FnbEngine.Domain.SeedWork; + +namespace FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +/// +/// EN: Barista queue item - represents a drink in the barista preparation queue. +/// VI: Barista queue item - dai dien cho do uong trong hang doi pha che. +/// +public class BaristaQueueItem : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private de dong goi + private Guid _shopId; + private Guid _orderId; + private Guid _orderItemId; + private string _drinkName = null!; + private string? _customizations; // JSON string + private int _priority; + private int _statusId; + private string? _assignedTo; // barista name + private int _estimatedMinutes; + private DateTime _createdAt; + private DateTime? _startedAt; + private DateTime? _completedAt; + + /// + /// EN: Shop ID where the drink was ordered. + /// VI: ID shop noi do uong duoc dat. + /// + public Guid ShopId => _shopId; + + /// + /// EN: Order ID from order-service. + /// VI: ID don hang tu order-service. + /// + public Guid OrderId => _orderId; + + /// + /// EN: Order item ID from order-service. + /// VI: ID item don hang tu order-service. + /// + public Guid OrderItemId => _orderItemId; + + /// + /// EN: Display name of the drink. + /// VI: Ten hien thi cua do uong. + /// + public string DrinkName => _drinkName; + + /// + /// EN: Customizations as JSON (e.g. ice level, sugar, toppings). + /// VI: Tuy chinh dang JSON (vd: muc da, duong, topping). + /// + public string? Customizations => _customizations; + + /// + /// EN: Priority (higher = more urgent). 0 is normal. + /// VI: Do uu tien (cao = gap hon). 0 la binh thuong. + /// + public int Priority => _priority; + + /// + /// EN: Status ID: 1=Queued, 2=Preparing, 3=Ready, 4=Delivered, 5=Cancelled. + /// VI: ID trang thai: 1=Cho, 2=Dang pha, 3=San sang, 4=Da giao, 5=Da huy. + /// + public int StatusId => _statusId; + + /// + /// EN: Status display name. + /// VI: Ten hien thi trang thai. + /// + public string StatusName => _statusId switch + { + 1 => "Queued", + 2 => "Preparing", + 3 => "Ready", + 4 => "Delivered", + 5 => "Cancelled", + _ => "Unknown" + }; + + /// + /// EN: Barista assigned to prepare this drink. + /// VI: Barista duoc giao pha che do uong nay. + /// + public string? AssignedTo => _assignedTo; + + /// + /// EN: Estimated preparation time in minutes. + /// VI: Thoi gian pha che du kien tinh bang phut. + /// + public int EstimatedMinutes => _estimatedMinutes; + + /// + /// EN: When the item was added to the queue. + /// VI: Khi item duoc them vao hang doi. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: When preparation started. + /// VI: Khi bat dau pha che. + /// + public DateTime? StartedAt => _startedAt; + + /// + /// EN: When the drink was completed (Ready or Delivered). + /// VI: Khi do uong hoan thanh (San sang hoac Da giao). + /// + public DateTime? CompletedAt => _completedAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected BaristaQueueItem() + { + } + + /// + /// EN: Create a new barista queue item. + /// VI: Tao barista queue item moi. + /// + public BaristaQueueItem( + Guid shopId, + Guid orderId, + Guid orderItemId, + string drinkName, + string? customizations = null, + int priority = 0, + int estimatedMinutes = 5) + { + if (shopId == Guid.Empty) + throw new ArgumentException("Shop ID cannot be empty / Shop ID khong duoc rong", nameof(shopId)); + if (orderId == Guid.Empty) + throw new ArgumentException("Order ID cannot be empty / Order ID khong duoc rong", nameof(orderId)); + if (orderItemId == Guid.Empty) + throw new ArgumentException("Order item ID cannot be empty / Order item ID khong duoc rong", nameof(orderItemId)); + if (string.IsNullOrWhiteSpace(drinkName)) + throw new ArgumentException("Drink name cannot be empty / Ten do uong khong duoc rong", nameof(drinkName)); + + Id = Guid.NewGuid(); + _shopId = shopId; + _orderId = orderId; + _orderItemId = orderItemId; + _drinkName = drinkName; + _customizations = customizations; + _priority = priority; + _statusId = 1; // Queued + _estimatedMinutes = estimatedMinutes > 0 ? estimatedMinutes : 5; + _createdAt = DateTime.UtcNow; + + // EN: Raise domain event for new drink queued + // VI: Phat domain event khi do uong moi duoc xep hang + AddDomainEvent(new DrinkQueuedDomainEvent(this)); + } + + /// + /// EN: Start preparing the drink. Assigns a barista. + /// VI: Bat dau pha che do uong. Giao cho barista. + /// + /// Name of the barista / Ten barista + public void StartPreparing(string baristaName) + { + if (_statusId != 1) // Not Queued + throw new InvalidOperationException( + $"Can only start preparing from Queued status. Current: {StatusName} / " + + $"Chi co the bat dau pha che tu trang thai Cho. Hien tai: {StatusName}"); + if (string.IsNullOrWhiteSpace(baristaName)) + throw new ArgumentException("Barista name is required / Ten barista la bat buoc", nameof(baristaName)); + + _statusId = 2; // Preparing + _assignedTo = baristaName; + _startedAt = DateTime.UtcNow; + } + + /// + /// EN: Mark the drink as ready for pickup. + /// VI: Danh dau do uong san sang de lay. + /// + public void MarkReady() + { + if (_statusId != 2) // Not Preparing + throw new InvalidOperationException( + $"Can only mark ready from Preparing status. Current: {StatusName} / " + + $"Chi co the danh dau san sang tu trang thai Dang pha. Hien tai: {StatusName}"); + + _statusId = 3; // Ready + _completedAt = DateTime.UtcNow; + + // EN: Raise domain event for drink ready notification + // VI: Phat domain event thong bao do uong san sang + AddDomainEvent(new DrinkReadyDomainEvent(this)); + } + + /// + /// EN: Mark the drink as delivered to the customer. + /// VI: Danh dau do uong da giao cho khach. + /// + public void MarkDelivered() + { + if (_statusId != 3) // Not Ready + throw new InvalidOperationException( + $"Can only mark delivered from Ready status. Current: {StatusName} / " + + $"Chi co the danh dau da giao tu trang thai San sang. Hien tai: {StatusName}"); + + _statusId = 4; // Delivered + if (_completedAt == null) + _completedAt = DateTime.UtcNow; + } + + /// + /// EN: Cancel the queue item. + /// VI: Huy item trong hang doi. + /// + public void Cancel() + { + if (_statusId == 4) // Delivered + throw new InvalidOperationException( + "Cannot cancel a delivered drink / Khong the huy do uong da giao"); + if (_statusId == 5) // Already cancelled + throw new InvalidOperationException( + "Drink is already cancelled / Do uong da bi huy roi"); + + _statusId = 5; // Cancelled + _completedAt = DateTime.UtcNow; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/IBaristaQueueRepository.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/IBaristaQueueRepository.cs new file mode 100644 index 00000000..2156ea55 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/BaristaAggregate/IBaristaQueueRepository.cs @@ -0,0 +1,62 @@ +// EN: Repository interface for BaristaQueueItem aggregate. +// VI: Interface repository cho aggregate BaristaQueueItem. + +using FnbEngine.Domain.SeedWork; + +namespace FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +/// +/// EN: Repository interface for BaristaQueueItem aggregate root. +/// VI: Interface repository cho aggregate root BaristaQueueItem. +/// +public interface IBaristaQueueRepository : IRepository +{ + /// + /// EN: Add a new queue item. + /// VI: Them item moi vao hang doi. + /// + Task AddAsync(BaristaQueueItem item, CancellationToken cancellationToken = default); + + /// + /// EN: Update an existing queue item. + /// VI: Cap nhat item hang doi hien tai. + /// + void Update(BaristaQueueItem item); + + /// + /// EN: Get queue item by ID. + /// VI: Lay item hang doi theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get active queue items for a shop sorted by priority then created time. + /// VI: Lay cac item hang doi dang hoat dong cho shop sap xep theo uu tien roi thoi gian tao. + /// + Task> GetActiveByShopAsync(Guid shopId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all queue items for a shop (including completed). + /// VI: Lay tat ca item hang doi cho shop (bao gom da hoan thanh). + /// + Task> GetByShopAsync(Guid shopId, CancellationToken cancellationToken = default); + + /// + /// EN: Get queue stats for a shop (counts by status, avg prep time). + /// VI: Lay thong ke hang doi cho shop (so luong theo trang thai, thoi gian pha che trung binh). + /// + Task GetStatsAsync(Guid shopId, CancellationToken cancellationToken = default); +} + +/// +/// EN: Queue statistics for a shop. +/// VI: Thong ke hang doi cho shop. +/// +public record BaristaQueueStats( + int TotalQueued, + int TotalPreparing, + int TotalReady, + int TotalDelivered, + int TotalCancelled, + double AveragePrepTimeMinutes +); diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkQueuedDomainEvent.cs b/services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkQueuedDomainEvent.cs new file mode 100644 index 00000000..4fa72579 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkQueuedDomainEvent.cs @@ -0,0 +1,13 @@ +// EN: Domain event raised when a drink is added to the barista queue. +// VI: Domain event phat ra khi do uong duoc them vao hang doi barista. + +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; +using MediatR; + +namespace FnbEngine.Domain.Events; + +/// +/// EN: Domain event raised when a new drink is queued for preparation. +/// VI: Domain event phat ra khi do uong moi duoc xep hang de pha che. +/// +public record DrinkQueuedDomainEvent(BaristaQueueItem QueueItem) : INotification; diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkReadyDomainEvent.cs b/services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkReadyDomainEvent.cs new file mode 100644 index 00000000..a39fdee5 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/Events/DrinkReadyDomainEvent.cs @@ -0,0 +1,13 @@ +// EN: Domain event raised when a drink is ready for pickup. +// VI: Domain event phat ra khi do uong san sang de lay. + +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; +using MediatR; + +namespace FnbEngine.Domain.Events; + +/// +/// EN: Domain event raised when a drink preparation is complete and ready for pickup. +/// VI: Domain event phat ra khi do uong pha che xong va san sang de lay. +/// +public record DrinkReadyDomainEvent(BaristaQueueItem QueueItem) : INotification; diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs index 5804c357..3c512d34 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; using FnbEngine.Domain.AggregatesModel.TableAggregate; using FnbEngine.Domain.AggregatesModel.SessionAggregate; using FnbEngine.Domain.AggregatesModel.KitchenAggregate; @@ -59,6 +60,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/BaristaQueueItemEntityTypeConfiguration.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/BaristaQueueItemEntityTypeConfiguration.cs new file mode 100644 index 00000000..cd317111 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/BaristaQueueItemEntityTypeConfiguration.cs @@ -0,0 +1,118 @@ +// EN: BaristaQueueItem entity configuration. +// VI: Cau hinh entity BaristaQueueItem. + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; + +namespace FnbEngine.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for BaristaQueueItem. +/// VI: Cau hinh entity cho BaristaQueueItem. +/// +public class BaristaQueueItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("barista_queue_items"); + + builder.HasKey(bq => bq.Id); + + builder.Property(bq => bq.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Shop ID + // VI: ID shop + builder.Property("_shopId") + .HasColumnName("shop_id") + .IsRequired(); + + // EN: Order ID + // VI: ID don hang + builder.Property("_orderId") + .HasColumnName("order_id") + .IsRequired(); + + // EN: Order item ID + // VI: ID item don hang + builder.Property("_orderItemId") + .HasColumnName("order_item_id") + .IsRequired(); + + // EN: Drink name + // VI: Ten do uong + builder.Property("_drinkName") + .HasColumnName("drink_name") + .HasMaxLength(200) + .IsRequired(); + + // EN: Customizations as JSON + // VI: Tuy chinh dang JSON + builder.Property("_customizations") + .HasColumnName("customizations") + .HasColumnType("jsonb"); + + // EN: Priority (higher = more urgent) + // VI: Do uu tien (cao = gap hon) + builder.Property("_priority") + .HasColumnName("priority") + .IsRequired() + .HasDefaultValue(0); + + // EN: Status ID (1=Queued, 2=Preparing, 3=Ready, 4=Delivered, 5=Cancelled) + // VI: ID trang thai (1=Cho, 2=Dang pha, 3=San sang, 4=Da giao, 5=Da huy) + builder.Property("_statusId") + .HasColumnName("status_id") + .IsRequired() + .HasDefaultValue(1); + + // EN: Assigned barista name + // VI: Ten barista duoc giao + builder.Property("_assignedTo") + .HasColumnName("assigned_to") + .HasMaxLength(100); + + // EN: Estimated preparation time in minutes + // VI: Thoi gian pha che du kien tinh bang phut + builder.Property("_estimatedMinutes") + .HasColumnName("estimated_minutes") + .IsRequired() + .HasDefaultValue(5); + + // EN: Timestamps + // VI: Timestamps + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_startedAt") + .HasColumnName("started_at"); + + builder.Property("_completedAt") + .HasColumnName("completed_at"); + + // EN: Ignore domain events (not persisted) + // VI: Bo qua domain events (khong luu) + builder.Ignore(bq => bq.DomainEvents); + + // EN: Indexes for common queries + // VI: Indexes cho cac query thuong dung + + // EN: Index for shop queue listing (active items sorted by priority) + // VI: Index cho danh sach hang doi shop (item dang hoat dong sap xep theo uu tien) + builder.HasIndex("_shopId", "_statusId", "_priority") + .HasDatabaseName("ix_barista_queue_shop_status_priority"); + + // EN: Index for order lookup + // VI: Index cho tra cuu don hang + builder.HasIndex("_orderId") + .HasDatabaseName("ix_barista_queue_order_id"); + + // EN: Index for created time sorting + // VI: Index cho sap xep theo thoi gian tao + builder.HasIndex("_createdAt") + .HasDatabaseName("ix_barista_queue_created_at"); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs index 905d22b9..4a55afa6 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; using FnbEngine.Domain.AggregatesModel.TableAggregate; using FnbEngine.Domain.AggregatesModel.SessionAggregate; using FnbEngine.Domain.AggregatesModel.KitchenAggregate; @@ -33,6 +34,7 @@ public class FnbContext : DbContext, IUnitOfWork public DbSet Recipes => Set(); public DbSet RecipeIngredients => Set(); public DbSet Reservations => Set(); + public DbSet BaristaQueueItems => Set(); public IDbContextTransaction? CurrentTransaction => _currentTransaction; public bool HasActiveTransaction => _currentTransaction != null; @@ -63,6 +65,7 @@ public class FnbContext : DbContext, IUnitOfWork modelBuilder.ApplyConfiguration(new RecipeEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new RecipeIngredientEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new ReservationEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new BaristaQueueItemEntityTypeConfiguration()); // EN: Global query filters for tenant isolation (shop-level). // Tables, Sessions, and Reservations have shop_id. @@ -89,6 +92,12 @@ public class FnbContext : DbContext, IUnitOfWork || _tenantProvider.ShouldBypassTenantFilter() || _tenantProvider.GetCurrentShopId() == null || r.ShopId == _tenantProvider.GetCurrentShopId()); + + modelBuilder.Entity().HasQueryFilter(bq => + _tenantProvider == null + || _tenantProvider.ShouldBypassTenantFilter() + || _tenantProvider.GetCurrentShopId() == null + || bq.ShopId == _tenantProvider.GetCurrentShopId()); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/BaristaQueueRepository.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/BaristaQueueRepository.cs new file mode 100644 index 00000000..9ecca48f --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/BaristaQueueRepository.cs @@ -0,0 +1,115 @@ +// EN: Repository implementation for BaristaQueueItem aggregate. +// VI: Trien khai repository cho aggregate BaristaQueueItem. + +using Microsoft.EntityFrameworkCore; +using FnbEngine.Domain.AggregatesModel.BaristaAggregate; +using FnbEngine.Domain.SeedWork; + +namespace FnbEngine.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for BaristaQueueItem aggregate. +/// VI: Trien khai repository cho aggregate BaristaQueueItem. +/// +public class BaristaQueueRepository : IBaristaQueueRepository +{ + private readonly FnbContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public BaristaQueueRepository(FnbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// EN: Add a new queue item. + /// VI: Them item moi vao hang doi. + /// + public async Task AddAsync(BaristaQueueItem item, CancellationToken cancellationToken = default) + { + return (await _context.BaristaQueueItems.AddAsync(item, cancellationToken)).Entity; + } + + /// + /// EN: Update an existing queue item. + /// VI: Cap nhat item hang doi hien tai. + /// + public void Update(BaristaQueueItem item) + { + _context.Entry(item).State = EntityState.Modified; + } + + /// + /// EN: Get queue item by ID. + /// VI: Lay item hang doi theo ID. + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.BaristaQueueItems + .FirstOrDefaultAsync(bq => bq.Id == id, cancellationToken); + } + + /// + /// EN: Get active queue items (Queued, Preparing, Ready) for a shop, sorted by priority desc then createdAt asc. + /// VI: Lay cac item dang hoat dong (Cho, Dang pha, San sang) cho shop, sap xep theo uu tien giam roi thoi gian tao tang. + /// + public async Task> GetActiveByShopAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.BaristaQueueItems + .Where(bq => EF.Property(bq, "_shopId") == shopId + && (EF.Property(bq, "_statusId") == 1 // Queued + || EF.Property(bq, "_statusId") == 2 // Preparing + || EF.Property(bq, "_statusId") == 3)) // Ready + .OrderByDescending(bq => EF.Property(bq, "_priority")) + .ThenBy(bq => EF.Property(bq, "_createdAt")) + .ToListAsync(cancellationToken); + } + + /// + /// EN: Get all queue items for a shop (including completed). + /// VI: Lay tat ca item hang doi cho shop (bao gom da hoan thanh). + /// + public async Task> GetByShopAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.BaristaQueueItems + .Where(bq => EF.Property(bq, "_shopId") == shopId) + .OrderByDescending(bq => EF.Property(bq, "_createdAt")) + .ToListAsync(cancellationToken); + } + + /// + /// EN: Get queue stats for a shop. + /// VI: Lay thong ke hang doi cho shop. + /// + public async Task GetStatsAsync(Guid shopId, CancellationToken cancellationToken = default) + { + var items = await _context.BaristaQueueItems + .Where(bq => EF.Property(bq, "_shopId") == shopId) + .ToListAsync(cancellationToken); + + var totalQueued = items.Count(i => i.StatusId == 1); + var totalPreparing = items.Count(i => i.StatusId == 2); + var totalReady = items.Count(i => i.StatusId == 3); + var totalDelivered = items.Count(i => i.StatusId == 4); + var totalCancelled = items.Count(i => i.StatusId == 5); + + // EN: Calculate average prep time from items that have both start and completion times + // VI: Tinh thoi gian pha che trung binh tu cac item co ca thoi gian bat dau va hoan thanh + var completedWithTimes = items + .Where(i => i.StartedAt.HasValue && i.CompletedAt.HasValue && i.StatusId != 5) + .ToList(); + + var avgPrepTime = completedWithTimes.Count > 0 + ? completedWithTimes.Average(i => (i.CompletedAt!.Value - i.StartedAt!.Value).TotalMinutes) + : 0.0; + + return new BaristaQueueStats( + totalQueued, + totalPreparing, + totalReady, + totalDelivered, + totalCancelled, + Math.Round(avgPrepTime, 2)); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs index 7acb4115..4f59463b 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs @@ -397,3 +397,39 @@ public class DeleteInventoryItemCommandHandler : IRequestHandler +/// EN: Handler for SetLowStockAlertCommand — sets the reorder level (low stock threshold). +/// VI: Handler cho SetLowStockAlertCommand — cài đặt mức đặt hàng lại (ngưỡng stock thấp). +/// +public class SetLowStockAlertCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public SetLowStockAlertCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(SetLowStockAlertCommand request, CancellationToken ct) + { + var item = await _repository.GetByProductAndShopAsync( + request.ProductId, request.ShopId, ct); + + if (item == null) + return false; + + item.SetReorderLevel(request.MinimumLevel); + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Low stock alert set / VI: Cảnh báo stock thấp đã cài đặt - Product: {ProductId}, Shop: {ShopId}, MinLevel: {MinLevel}", + request.ProductId, request.ShopId, request.MinimumLevel); + + return true; + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs index 938e15e4..01c33b7b 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs @@ -102,6 +102,15 @@ public record StocktakeResult(List Discrepancies, int Tota public record StocktakeDiscrepancy(Guid InventoryItemId, string? ItemName, int ExpectedQuantity, int CountedQuantity, int Difference); +/// +/// EN: Command to set low stock alert threshold for a product. +/// VI: Command để cài đặt ngưỡng cảnh báo stock thấp cho sản phẩm. +/// +public record SetLowStockAlertCommand( + Guid ShopId, + Guid ProductId, + int MinimumLevel) : IRequest; + /// /// EN: Command to delete an inventory item. /// VI: Command để xóa một inventory item. diff --git a/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs index 0f7ad42e..576ee5a8 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs @@ -39,6 +39,34 @@ public record InventoryTransactionDto( decimal? UnitCost, DateTime CreatedAt); +/// +/// EN: DTO for stock level response (used by POS for cart validation). +/// VI: DTO cho response mức tồn kho (dùng bởi POS cho xác thực giỏ hàng). +/// +public record StockLevelDto( + Guid ProductId, + int Available, + int Reserved, + int Minimum, + bool IsLowStock); + +/// +/// EN: Request for bulk stock check (for cart validation). +/// VI: Request cho kiểm tra stock hàng loạt (cho xác thực giỏ hàng). +/// +public record BulkStockCheckRequest( + Guid ShopId, + List ProductIds); + +/// +/// EN: Request for setting low stock alert threshold. +/// VI: Request cho cài đặt ngưỡng cảnh báo stock thấp. +/// +public record SetLowStockAlertRequest( + Guid ShopId, + Guid ProductId, + int MinimumLevel); + /// /// EN: Request for stock in operation. /// VI: Request cho thao tác nhập kho. diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueries.cs b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueries.cs index 1ecf4e85..9edc92a6 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueries.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueries.cs @@ -41,6 +41,14 @@ public record GetTransactionsByShopQuery( int Skip = 0, int Take = 50) : IRequest>; +/// +/// EN: Query to get stock levels for multiple products (bulk check for POS cart validation). +/// VI: Query lấy mức tồn kho cho nhiều sản phẩm (kiểm tra hàng loạt cho xác thực giỏ hàng POS). +/// +public record GetStockLevelsQuery( + Guid ShopId, + List ProductIds) : IRequest>; + /// /// EN: Query to get low stock items. /// VI: Query lấy các items stock thấp. diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueryHandlers.cs b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueryHandlers.cs index 89fc036a..a9aaa3ef 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueryHandlers.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueryHandlers.cs @@ -125,6 +125,50 @@ public class GetTransactionsByShopQueryHandler } } +/// +/// EN: Handler for GetStockLevelsQuery (bulk stock check for POS cart validation). +/// VI: Handler cho GetStockLevelsQuery (kiểm tra stock hàng loạt cho xác thực giỏ hàng POS). +/// +public class GetStockLevelsQueryHandler + : IRequestHandler> +{ + private readonly IInventoryRepository _repository; + + public GetStockLevelsQueryHandler(IInventoryRepository repository) + { + _repository = repository; + } + + public async Task> Handle( + GetStockLevelsQuery request, + CancellationToken ct) + { + var items = await _repository.GetByProductIdsAndShopAsync( + request.ShopId, request.ProductIds, ct); + + // EN: Map inventory items to StockLevelDto; include entries for missing products. + // VI: Map inventory items sang StockLevelDto; bao gồm entries cho các product thiếu. + var itemsByProduct = items.ToDictionary(i => i.ProductId); + + return request.ProductIds.Select(pid => + { + if (itemsByProduct.TryGetValue(pid, out var item)) + { + return new StockLevelDto( + pid, + item.AvailableQuantity, + item.ReservedQuantity, + item.ReorderLevel, + item.AvailableQuantity <= item.ReorderLevel); + } + + // EN: Product has no inventory record — report as zero stock. + // VI: Product không có inventory record — báo cáo stock bằng 0. + return new StockLevelDto(pid, 0, 0, 0, true); + }).ToList(); + } +} + /// /// EN: Handler for GetLowStockItemsQuery. /// VI: Handler cho GetLowStockItemsQuery. diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs b/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs index f1c0983e..e9cf6b69 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs @@ -169,3 +169,25 @@ public class AdjustStockCommandValidator : AbstractValidator .WithMessage("Notes are required for adjustment / Ghi chú bắt buộc cho điều chỉnh"); } } + +/// +/// EN: Validator for SetLowStockAlertCommand. +/// VI: Validator cho SetLowStockAlertCommand. +/// +public class SetLowStockAlertCommandValidator : AbstractValidator +{ + public SetLowStockAlertCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID bắt buộc"); + + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID bắt buộc"); + + RuleFor(x => x.MinimumLevel) + .GreaterThanOrEqualTo(0) + .WithMessage("Minimum level must be >= 0 / Mức tối thiểu phải >= 0"); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs index 5c09943e..9c9d5c30 100644 --- a/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs +++ b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs @@ -76,6 +76,64 @@ public class InventoryController : ControllerBase return Ok(ApiResponse.Ok(result)); } + /// + /// EN: Bulk stock check for POS cart validation. + /// VI: Kiểm tra stock hàng loạt cho xác thực giỏ hàng POS. + /// + [HttpPost("stock/bulk")] + [SwaggerOperation(Summary = "Bulk stock check for multiple products (POS cart validation)")] + [SwaggerResponse(200, "Stock levels retrieved successfully")] + [SwaggerResponse(400, "Invalid request")] + public async Task>>> BulkStockCheck( + [FromBody] BulkStockCheckRequest request, + CancellationToken ct = default) + { + if (request.ShopId == Guid.Empty) + return BadRequest(ApiResponse>.Fail("Shop ID is required / Shop ID là bắt buộc")); + + if (request.ProductIds == null || request.ProductIds.Count == 0) + return BadRequest(ApiResponse>.Fail("At least one product ID is required / Cần ít nhất một product ID")); + + var query = new GetStockLevelsQuery(request.ShopId, request.ProductIds); + var result = await _mediator.Send(query, ct); + + return Ok(ApiResponse>.Ok(result)); + } + + /// + /// EN: Set low stock alert threshold for a product. + /// VI: Cài đặt ngưỡng cảnh báo stock thấp cho sản phẩm. + /// + [HttpPut("alerts")] + [SwaggerOperation(Summary = "Set low stock alert threshold")] + [SwaggerResponse(200, "Alert threshold set successfully")] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> SetLowStockAlert( + [FromBody] SetLowStockAlertRequest request, + CancellationToken ct = default) + { + try + { + var command = new SetLowStockAlertCommand( + request.ShopId, + request.ProductId, + request.MinimumLevel); + + var result = await _mediator.Send(command, ct); + + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found / Không tìm thấy inventory item")); + + return Ok(ApiResponse.Ok(true)); + } + catch (Exception ex) + { + _logger.LogError(ex, "EN: Error setting low stock alert / VI: Lỗi cài đặt cảnh báo stock thấp"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + /// /// EN: Create a new inventory item (raw material, consumable, etc.). /// VI: Tạo mặt hàng tồn kho mới (nguyên liệu, vật tư tiêu hao, v.v.). diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs index af83506d..2244d79c 100644 --- a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs @@ -87,6 +87,15 @@ public interface IInventoryRepository : IRepository int take = 50, CancellationToken cancellationToken = default); + /// + /// EN: Get inventory items by multiple product IDs and shop (for bulk stock check). + /// VI: Lấy inventory items theo nhiều product IDs và shop (cho kiểm tra stock hàng loạt). + /// + Task> GetByProductIdsAndShopAsync( + Guid shopId, + IEnumerable productIds, + CancellationToken cancellationToken = default); + /// /// EN: Add inventory item asynchronously. /// VI: Thêm inventory item bất đồng bộ. diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs index 2356ae9a..ff4a1248 100644 --- a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs @@ -297,6 +297,19 @@ public class InventoryItem : Entity, IAggregateRoot _transactions.Add(transaction); } + /// + /// EN: Set reorder level (low stock alert threshold). + /// VI: Đặt mức đặt hàng lại (ngưỡng cảnh báo stock thấp). + /// + public void SetReorderLevel(int reorderLevel) + { + if (reorderLevel < 0) + throw new DomainException("Reorder level cannot be negative"); + + _reorderLevel = reorderLevel; + _updatedAt = DateTime.UtcNow; + } + /// /// EN: Manual adjustment (stocktake). /// VI: Điều chỉnh thủ công (kiểm kê). diff --git a/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs b/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs index 94678023..fda961ce 100644 --- a/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs +++ b/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs @@ -132,6 +132,17 @@ public class InventoryRepository : IInventoryRepository return (paged, total); } + public async Task> GetByProductIdsAndShopAsync( + Guid shopId, + IEnumerable productIds, + CancellationToken cancellationToken = default) + { + var ids = productIds.ToList(); + return await _context.InventoryItems + .Where(i => i.ShopId == shopId && ids.Contains(i.ProductId)) + .ToListAsync(cancellationToken); + } + public async Task AddAsync(InventoryItem item, CancellationToken cancellationToken = default) { var entity = await _context.InventoryItems.AddAsync(item, cancellationToken); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommand.cs new file mode 100644 index 00000000..03cce2fa --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommand.cs @@ -0,0 +1,25 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to add a stamp to a member's active card after a purchase. +/// VI: Command de them stamp vao card dang hoat dong cua member sau khi mua hang. +/// +public record AddStampCommand( + Guid ShopId, + Guid MemberId, + Guid? OrderId = null +) : IRequest; + +/// +/// EN: Result of add stamp command. +/// VI: Ket qua cua add stamp command. +/// +public record AddStampResult( + Guid StampCardId, + int CurrentStamps, + int TotalStampsRequired, + bool IsCompleted, + bool CardWasAutoCreated +); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommandHandler.cs new file mode 100644 index 00000000..b618a3f5 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddStampCommandHandler.cs @@ -0,0 +1,67 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for adding a stamp to a member's card. Auto-creates card if none exists. +/// VI: Handler de them stamp vao card cua member. Tu dong tao card neu chua co. +/// +public class AddStampCommandHandler : IRequestHandler +{ + private readonly IStampCardRepository _stampCardRepository; + private readonly ILogger _logger; + + public AddStampCommandHandler( + IStampCardRepository stampCardRepository, + ILogger logger) + { + _stampCardRepository = stampCardRepository ?? throw new ArgumentNullException(nameof(stampCardRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(AddStampCommand request, CancellationToken cancellationToken) + { + var cardWasAutoCreated = false; + + // EN: Find active card for member+shop + // VI: Tim card dang hoat dong cho member+shop + var stampCard = await _stampCardRepository.GetActiveCardAsync( + request.ShopId, request.MemberId, cancellationToken); + + // EN: If no active card exists, auto-create one + // VI: Neu khong co card dang hoat dong, tu dong tao moi + if (stampCard == null) + { + stampCard = new StampCard( + request.ShopId, + request.MemberId, + "Loyalty Card", + totalStampsRequired: 10); + + _stampCardRepository.Add(stampCard); + cardWasAutoCreated = true; + + _logger.LogInformation( + "Auto-created stamp card {StampCardId} for member {MemberId} at shop {ShopId}", + stampCard.Id, request.MemberId, request.ShopId); + } + + // EN: Add stamp to the card + // VI: Them stamp vao card + stampCard.AddStamp(); + _stampCardRepository.Update(stampCard); + await _stampCardRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Added stamp to card {StampCardId}: {Current}/{Total} (Order: {OrderId})", + stampCard.Id, stampCard.CurrentStamps, stampCard.TotalStampsRequired, request.OrderId); + + return new AddStampResult( + stampCard.Id, + stampCard.CurrentStamps, + stampCard.TotalStampsRequired, + stampCard.IsCompleted, + cardWasAutoCreated); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommand.cs new file mode 100644 index 00000000..99309e5d --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to claim a reward on a completed stamp card. +/// VI: Command de nhan thuong tren stamp card da hoan thanh. +/// +public record ClaimRewardCommand(Guid StampCardId) : IRequest; + +/// +/// EN: Result of claim reward command. +/// VI: Ket qua cua claim reward command. +/// +public record ClaimRewardResult( + Guid StampCardId, + bool RewardClaimed, + string Message +); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommandHandler.cs new file mode 100644 index 00000000..0b5f1b6b --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/ClaimRewardCommandHandler.cs @@ -0,0 +1,48 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for claiming a reward on a completed stamp card. +/// VI: Handler de nhan thuong tren stamp card da hoan thanh. +/// +public class ClaimRewardCommandHandler : IRequestHandler +{ + private readonly IStampCardRepository _stampCardRepository; + private readonly ILogger _logger; + + public ClaimRewardCommandHandler( + IStampCardRepository stampCardRepository, + ILogger logger) + { + _stampCardRepository = stampCardRepository ?? throw new ArgumentNullException(nameof(stampCardRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(ClaimRewardCommand request, CancellationToken cancellationToken) + { + // EN: Find the stamp card + // VI: Tim stamp card + var stampCard = await _stampCardRepository.GetByIdAsync(request.StampCardId, cancellationToken); + if (stampCard == null) + { + throw new KeyNotFoundException( + $"Stamp card {request.StampCardId} not found / Stamp card {request.StampCardId} khong tim thay"); + } + + // EN: Claim the reward (domain validates completion) + // VI: Nhan thuong (domain kiem tra hoan thanh) + stampCard.ClaimReward(); + _stampCardRepository.Update(stampCard); + await _stampCardRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Reward claimed on stamp card {StampCardId} for member {MemberId}", + stampCard.Id, stampCard.MemberId); + + return new ClaimRewardResult( + stampCard.Id, + true, + "Reward claimed successfully! / Nhan thuong thanh cong!"); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommand.cs new file mode 100644 index 00000000..48f759e5 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommand.cs @@ -0,0 +1,27 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to create a new loyalty stamp card. +/// VI: Command de tao stamp card loyalty moi. +/// +public record CreateStampCardCommand( + Guid ShopId, + Guid MemberId, + string CardName, + int TotalStampsRequired = 10 +) : IRequest; + +/// +/// EN: Result of create stamp card command. +/// VI: Ket qua cua create stamp card command. +/// +public record CreateStampCardResult( + Guid StampCardId, + Guid ShopId, + Guid MemberId, + string CardName, + int TotalStampsRequired, + DateTime CreatedAt +); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommandHandler.cs new file mode 100644 index 00000000..c05473f4 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateStampCardCommandHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for creating a new loyalty stamp card. +/// VI: Handler de tao stamp card loyalty moi. +/// +public class CreateStampCardCommandHandler : IRequestHandler +{ + private readonly IStampCardRepository _stampCardRepository; + private readonly ILogger _logger; + + public CreateStampCardCommandHandler( + IStampCardRepository stampCardRepository, + ILogger logger) + { + _stampCardRepository = stampCardRepository ?? throw new ArgumentNullException(nameof(stampCardRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CreateStampCardCommand request, CancellationToken cancellationToken) + { + // EN: Check if member already has an active card at this shop + // VI: Kiem tra member da co card dang hoat dong tai shop nay chua + var existingCard = await _stampCardRepository.GetActiveCardAsync(request.ShopId, request.MemberId, cancellationToken); + if (existingCard != null) + { + throw new InvalidOperationException( + $"Member {request.MemberId} already has an active stamp card at shop {request.ShopId} / " + + $"Member {request.MemberId} da co stamp card dang hoat dong tai shop {request.ShopId}"); + } + + // EN: Create new stamp card + // VI: Tao stamp card moi + var stampCard = new StampCard( + request.ShopId, + request.MemberId, + request.CardName, + request.TotalStampsRequired); + + _stampCardRepository.Add(stampCard); + await _stampCardRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Created stamp card {StampCardId} for member {MemberId} at shop {ShopId} with {TotalStamps} stamps required", + stampCard.Id, request.MemberId, request.ShopId, request.TotalStampsRequired); + + return new CreateStampCardResult( + stampCard.Id, + stampCard.ShopId, + stampCard.MemberId, + stampCard.CardName, + stampCard.TotalStampsRequired, + stampCard.CreatedAt); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommand.cs new file mode 100644 index 00000000..8c312899 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommand.cs @@ -0,0 +1,20 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to reset a stamp card for a new round after reward has been claimed. +/// VI: Command de reset stamp card cho vong moi sau khi da nhan thuong. +/// +public record ResetStampCardCommand(Guid StampCardId) : IRequest; + +/// +/// EN: Result of reset stamp card command. +/// VI: Ket qua cua reset stamp card command. +/// +public record ResetStampCardResult( + Guid StampCardId, + int CurrentStamps, + int TotalStampsRequired, + string Message +); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommandHandler.cs new file mode 100644 index 00000000..94730080 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/ResetStampCardCommandHandler.cs @@ -0,0 +1,48 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for resetting a stamp card to start a new round. +/// VI: Handler de reset stamp card bat dau vong moi. +/// +public class ResetStampCardCommandHandler : IRequestHandler +{ + private readonly IStampCardRepository _stampCardRepository; + private readonly ILogger _logger; + + public ResetStampCardCommandHandler( + IStampCardRepository stampCardRepository, + ILogger logger) + { + _stampCardRepository = stampCardRepository ?? throw new ArgumentNullException(nameof(stampCardRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(ResetStampCardCommand request, CancellationToken cancellationToken) + { + // EN: Find the stamp card + // VI: Tim stamp card + var stampCard = await _stampCardRepository.GetByIdAsync(request.StampCardId, cancellationToken); + if (stampCard == null) + { + throw new KeyNotFoundException( + $"Stamp card {request.StampCardId} not found / Stamp card {request.StampCardId} khong tim thay"); + } + + // EN: Reset the card (domain validates reward was claimed) + // VI: Reset card (domain kiem tra thuong da duoc nhan) + stampCard.Reset(); + _stampCardRepository.Update(stampCard); + await _stampCardRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Stamp card {StampCardId} reset for new round", stampCard.Id); + + return new ResetStampCardResult( + stampCard.Id, + stampCard.CurrentStamps, + stampCard.TotalStampsRequired, + "Stamp card reset for a new round! / Stamp card da reset cho vong moi!"); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQuery.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQuery.cs new file mode 100644 index 00000000..736267cd --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQuery.cs @@ -0,0 +1,26 @@ +using MediatR; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Query to get stamp card status for a member at a shop. +/// VI: Query de lay trang thai stamp card cho member tai shop. +/// +public record GetStampCardQuery(Guid ShopId, Guid MemberId) : IRequest; + +/// +/// EN: Stamp card DTO. +/// VI: DTO stamp card. +/// +public record StampCardDto( + Guid Id, + Guid ShopId, + Guid MemberId, + string CardName, + int TotalStampsRequired, + int CurrentStamps, + bool IsCompleted, + bool RewardClaimed, + DateTime CreatedAt, + DateTime? CompletedAt +); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQueryHandler.cs new file mode 100644 index 00000000..e8434a9f --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardQueryHandler.cs @@ -0,0 +1,39 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Handler for getting stamp card status for a member at a shop. +/// VI: Handler de lay trang thai stamp card cho member tai shop. +/// +public class GetStampCardQueryHandler : IRequestHandler +{ + private readonly IStampCardRepository _stampCardRepository; + + public GetStampCardQueryHandler(IStampCardRepository stampCardRepository) + { + _stampCardRepository = stampCardRepository ?? throw new ArgumentNullException(nameof(stampCardRepository)); + } + + public async Task Handle(GetStampCardQuery request, CancellationToken cancellationToken) + { + var card = await _stampCardRepository.GetByMemberAndShopAsync( + request.ShopId, request.MemberId, cancellationToken); + + if (card == null) + return null; + + return new StampCardDto( + card.Id, + card.ShopId, + card.MemberId, + card.CardName, + card.TotalStampsRequired, + card.CurrentStamps, + card.IsCompleted, + card.RewardClaimed, + card.CreatedAt, + card.CompletedAt); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQuery.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQuery.cs new file mode 100644 index 00000000..6d65704a --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQuery.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Query to get all stamp cards for a shop (admin view). +/// VI: Query de lay tat ca stamp cards cho shop (admin view). +/// +public record GetStampCardsQuery( + Guid ShopId, + int PageIndex = 0, + int PageSize = 20 +) : IRequest; + +/// +/// EN: Paginated result of stamp cards. +/// VI: Ket qua phan trang cua stamp cards. +/// +public record GetStampCardsResult( + IReadOnlyList Items, + int TotalCount, + int PageIndex, + int PageSize +); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQueryHandler.cs new file mode 100644 index 00000000..89e24a9b --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetStampCardsQueryHandler.cs @@ -0,0 +1,38 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Handler for getting all stamp cards for a shop (admin view). +/// VI: Handler de lay tat ca stamp cards cho shop (admin view). +/// +public class GetStampCardsQueryHandler : IRequestHandler +{ + private readonly IStampCardRepository _stampCardRepository; + + public GetStampCardsQueryHandler(IStampCardRepository stampCardRepository) + { + _stampCardRepository = stampCardRepository ?? throw new ArgumentNullException(nameof(stampCardRepository)); + } + + public async Task Handle(GetStampCardsQuery request, CancellationToken cancellationToken) + { + var (cards, totalCount) = await _stampCardRepository.GetByShopAsync( + request.ShopId, request.PageIndex, request.PageSize, cancellationToken); + + var items = cards.Select(card => new StampCardDto( + card.Id, + card.ShopId, + card.MemberId, + card.CardName, + card.TotalStampsRequired, + card.CurrentStamps, + card.IsCompleted, + card.RewardClaimed, + card.CreatedAt, + card.CompletedAt)).ToList(); + + return new GetStampCardsResult(items, totalCount, request.PageIndex, request.PageSize); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Validations/AddStampCommandValidator.cs b/services/membership-service-net/src/MembershipService.API/Application/Validations/AddStampCommandValidator.cs new file mode 100644 index 00000000..ff530a55 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Validations/AddStampCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using MembershipService.API.Application.Commands; + +namespace MembershipService.API.Application.Validations; + +/// +/// EN: Validator for AddStampCommand. +/// VI: Validator cho AddStampCommand. +/// +public class AddStampCommandValidator : AbstractValidator +{ + public AddStampCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("ShopId is required / ShopId la bat buoc"); + + RuleFor(x => x.MemberId) + .NotEmpty() + .WithMessage("MemberId is required / MemberId la bat buoc"); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Validations/ClaimRewardCommandValidator.cs b/services/membership-service-net/src/MembershipService.API/Application/Validations/ClaimRewardCommandValidator.cs new file mode 100644 index 00000000..e5cd3c65 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Validations/ClaimRewardCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using MembershipService.API.Application.Commands; + +namespace MembershipService.API.Application.Validations; + +/// +/// EN: Validator for ClaimRewardCommand. +/// VI: Validator cho ClaimRewardCommand. +/// +public class ClaimRewardCommandValidator : AbstractValidator +{ + public ClaimRewardCommandValidator() + { + RuleFor(x => x.StampCardId) + .NotEmpty() + .WithMessage("StampCardId is required / StampCardId la bat buoc"); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Validations/CreateStampCardCommandValidator.cs b/services/membership-service-net/src/MembershipService.API/Application/Validations/CreateStampCardCommandValidator.cs new file mode 100644 index 00000000..ff79eefe --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Validations/CreateStampCardCommandValidator.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using MembershipService.API.Application.Commands; + +namespace MembershipService.API.Application.Validations; + +/// +/// EN: Validator for CreateStampCardCommand. +/// VI: Validator cho CreateStampCardCommand. +/// +public class CreateStampCardCommandValidator : AbstractValidator +{ + public CreateStampCardCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("ShopId is required / ShopId la bat buoc"); + + RuleFor(x => x.MemberId) + .NotEmpty() + .WithMessage("MemberId is required / MemberId la bat buoc"); + + RuleFor(x => x.CardName) + .NotEmpty() + .WithMessage("Card name is required / Ten card la bat buoc") + .MaximumLength(200) + .WithMessage("Card name must not exceed 200 characters / Ten card khong duoc vuot qua 200 ky tu"); + + RuleFor(x => x.TotalStampsRequired) + .GreaterThanOrEqualTo(1) + .WithMessage("Total stamps required must be at least 1 / Tong stamp can it nhat la 1") + .LessThanOrEqualTo(100) + .WithMessage("Total stamps required must not exceed 100 / Tong stamp can khong duoc vuot qua 100"); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Validations/ResetStampCardCommandValidator.cs b/services/membership-service-net/src/MembershipService.API/Application/Validations/ResetStampCardCommandValidator.cs new file mode 100644 index 00000000..b4e68c77 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Validations/ResetStampCardCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using MembershipService.API.Application.Commands; + +namespace MembershipService.API.Application.Validations; + +/// +/// EN: Validator for ResetStampCardCommand. +/// VI: Validator cho ResetStampCardCommand. +/// +public class ResetStampCardCommandValidator : AbstractValidator +{ + public ResetStampCardCommandValidator() + { + RuleFor(x => x.StampCardId) + .NotEmpty() + .WithMessage("StampCardId is required / StampCardId la bat buoc"); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Controllers/StampCardsController.cs b/services/membership-service-net/src/MembershipService.API/Controllers/StampCardsController.cs new file mode 100644 index 00000000..fbe882f5 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Controllers/StampCardsController.cs @@ -0,0 +1,173 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MembershipService.API.Application.Commands; +using MembershipService.API.Application.Queries; +using Swashbuckle.AspNetCore.Annotations; + +namespace MembershipService.API.Controllers; + +/// +/// EN: Controller for managing loyalty stamp cards. +/// VI: Controller de quan ly stamp card loyalty. +/// +[ApiController] +[Route("api/v{version:apiVersion}/members/stamp-cards")] +[ApiVersion("1.0")] +[Authorize] +[SwaggerTag("Loyalty stamp card management endpoints")] +public class StampCardsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public StampCardsController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get stamp card status for a member at a shop. + /// VI: Lay trang thai stamp card cho member tai shop. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get stamp card", Description = "Retrieves stamp card status for a member at a shop")] + [SwaggerResponse(200, "Stamp card found", typeof(StampCardDto))] + [SwaggerResponse(404, "Stamp card not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> GetStampCard( + [FromQuery] Guid shopId, + [FromQuery] Guid memberId, + CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetStampCardQuery(shopId, memberId), cancellationToken); + if (result == null) + { + return NotFound(new { success = false, error = new { code = "STAMP_CARD_NOT_FOUND", message = "No stamp card found for this member at this shop / Khong tim thay stamp card cho member nay tai shop nay" } }); + } + return Ok(new { success = true, data = result }); + } + + /// + /// EN: Get all stamp cards for a shop (admin view). + /// VI: Lay tat ca stamp cards cho shop (admin view). + /// + [HttpGet("shop/{shopId:guid}")] + [SwaggerOperation(Summary = "Get shop stamp cards", Description = "Retrieves all stamp cards for a shop")] + [SwaggerResponse(200, "Stamp cards retrieved", typeof(GetStampCardsResult))] + [SwaggerResponse(401, "Unauthorized")] + public async Task> GetShopStampCards( + Guid shopId, + [FromQuery] int pageIndex = 0, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send( + new GetStampCardsQuery(shopId, pageIndex, pageSize), cancellationToken); + return Ok(new { success = true, data = result }); + } + + /// + /// EN: Create a new stamp card. + /// VI: Tao stamp card moi. + /// + [HttpPost] + [SwaggerOperation(Summary = "Create stamp card", Description = "Creates a new loyalty stamp card")] + [SwaggerResponse(201, "Stamp card created", typeof(CreateStampCardResult))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(409, "Active stamp card already exists")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> Create( + [FromBody] CreateStampCardCommand command, + CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send(command, cancellationToken); + return CreatedAtAction(nameof(GetStampCard), + new { shopId = result.ShopId, memberId = result.MemberId }, + new { success = true, data = result }); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("already has an active")) + { + return Conflict(new { success = false, error = new { code = "STAMP_CARD_EXISTS", message = ex.Message } }); + } + } + + /// + /// EN: Add a stamp after a purchase. + /// VI: Them stamp sau khi mua hang. + /// + [HttpPost("stamp")] + [SwaggerOperation(Summary = "Add stamp", Description = "Adds a stamp to a member's active card (auto-creates if none exists)")] + [SwaggerResponse(200, "Stamp added", typeof(AddStampResult))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> AddStamp( + [FromBody] AddStampCommand command, + CancellationToken cancellationToken) + { + var result = await _mediator.Send(command, cancellationToken); + return Ok(new { success = true, data = result }); + } + + /// + /// EN: Claim reward on a completed stamp card. + /// VI: Nhan thuong tren stamp card da hoan thanh. + /// + [HttpPost("{id:guid}/claim")] + [SwaggerOperation(Summary = "Claim reward", Description = "Claims the reward on a completed stamp card")] + [SwaggerResponse(200, "Reward claimed", typeof(ClaimRewardResult))] + [SwaggerResponse(400, "Card not completed or reward already claimed")] + [SwaggerResponse(404, "Stamp card not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> ClaimReward( + Guid id, + CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send(new ClaimRewardCommand(id), cancellationToken); + return Ok(new { success = true, data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { success = false, error = new { code = "STAMP_CARD_NOT_FOUND", message = ex.Message } }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { success = false, error = new { code = "INVALID_OPERATION", message = ex.Message } }); + } + } + + /// + /// EN: Reset a stamp card for a new round (after reward claimed). + /// VI: Reset stamp card cho vong moi (sau khi da nhan thuong). + /// + [HttpPost("{id:guid}/reset")] + [SwaggerOperation(Summary = "Reset stamp card", Description = "Resets a stamp card for a new round after reward is claimed")] + [SwaggerResponse(200, "Stamp card reset", typeof(ResetStampCardResult))] + [SwaggerResponse(400, "Reward not yet claimed")] + [SwaggerResponse(404, "Stamp card not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> ResetCard( + Guid id, + CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send(new ResetStampCardCommand(id), cancellationToken); + return Ok(new { success = true, data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { success = false, error = new { code = "STAMP_CARD_NOT_FOUND", message = ex.Message } }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { success = false, error = new { code = "INVALID_OPERATION", message = ex.Message } }); + } + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/IStampCardRepository.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/IStampCardRepository.cs new file mode 100644 index 00000000..f1e28cf7 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/IStampCardRepository.cs @@ -0,0 +1,50 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.StampCardAggregate; + +/// +/// EN: Repository interface for StampCard aggregate. +/// VI: Giao dien repository cho StampCard aggregate. +/// +public interface IStampCardRepository : IRepository +{ + /// + /// EN: Get stamp card by ID. + /// VI: Lay stamp card theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get active (non-completed or completed but unclaimed) stamp card for a member at a shop. + /// VI: Lay stamp card dang hoat dong (chua hoan thanh hoac hoan thanh nhung chua nhan thuong) cho member tai shop. + /// + Task GetActiveCardAsync(Guid shopId, Guid memberId, CancellationToken cancellationToken = default); + + /// + /// EN: Get stamp card by member and shop. + /// VI: Lay stamp card theo member va shop. + /// + Task GetByMemberAndShopAsync(Guid shopId, Guid memberId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all stamp cards for a shop (admin view). + /// VI: Lay tat ca stamp cards cho shop (admin view). + /// + Task<(IEnumerable Cards, int TotalCount)> GetByShopAsync( + Guid shopId, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// EN: Add a new stamp card. + /// VI: Them stamp card moi. + /// + StampCard Add(StampCard stampCard); + + /// + /// EN: Update an existing stamp card. + /// VI: Cap nhat stamp card hien tai. + /// + void Update(StampCard stampCard); +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/StampCard.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/StampCard.cs new file mode 100644 index 00000000..45c24d84 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/StampCardAggregate/StampCard.cs @@ -0,0 +1,181 @@ +using MembershipService.Domain.Events; +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.StampCardAggregate; + +/// +/// EN: Stamp card aggregate root - digital loyalty stamp card for cafe (buy N get 1 free). +/// VI: Stamp card aggregate root - the stamp ky thuat so cho cafe (mua N tang 1). +/// +public class StampCard : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private de dong goi + private Guid _shopId; + private Guid _memberId; + private string _cardName; + private int _totalStampsRequired; + private int _currentStamps; + private bool _isCompleted; + private bool _rewardClaimed; + private DateTime _createdAt; + private DateTime? _completedAt; + + /// + /// EN: Shop ID that issued this stamp card. + /// VI: ID shop da phat hanh stamp card nay. + /// + public Guid ShopId => _shopId; + + /// + /// EN: Member ID who owns this stamp card. + /// VI: ID member so huu stamp card nay. + /// + public Guid MemberId => _memberId; + + /// + /// EN: Display name of the stamp card (e.g. "Coffee Lovers Card"). + /// VI: Ten hien thi cua stamp card (vd: "The Coffee Lovers"). + /// + public string CardName => _cardName; + + /// + /// EN: Total stamps required to complete the card and earn a reward. + /// VI: Tong so stamp can de hoan thanh card va nhan thuong. + /// + public int TotalStampsRequired => _totalStampsRequired; + + /// + /// EN: Current number of stamps collected. + /// VI: So stamp hien tai da thu thap. + /// + public int CurrentStamps => _currentStamps; + + /// + /// EN: Whether the card has been completed (all stamps collected). + /// VI: Card da hoan thanh chua (da thu thap du stamp). + /// + public bool IsCompleted => _isCompleted; + + /// + /// EN: Whether the reward has been claimed after completion. + /// VI: Phan thuong da duoc nhan sau khi hoan thanh chua. + /// + public bool RewardClaimed => _rewardClaimed; + + /// + /// EN: Creation timestamp. + /// VI: Thoi gian tao. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Completion timestamp (when all stamps collected). + /// VI: Thoi gian hoan thanh (khi thu thap du stamp). + /// + public DateTime? CompletedAt => _completedAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected StampCard() + { + _cardName = string.Empty; + } + + /// + /// EN: Create a new stamp card for a member at a shop. + /// VI: Tao stamp card moi cho member tai shop. + /// + /// Shop ID / ID shop + /// Member ID / ID member + /// Card display name / Ten hien thi card + /// Total stamps needed for reward (default 10) / Tong stamp can cho thuong (mac dinh 10) + public StampCard(Guid shopId, Guid memberId, string cardName, int totalStampsRequired = 10) : this() + { + if (shopId == Guid.Empty) + throw new ArgumentException("Shop ID cannot be empty / Shop ID khong duoc rong", nameof(shopId)); + if (memberId == Guid.Empty) + throw new ArgumentException("Member ID cannot be empty / Member ID khong duoc rong", nameof(memberId)); + if (string.IsNullOrWhiteSpace(cardName)) + throw new ArgumentException("Card name cannot be empty / Ten card khong duoc rong", nameof(cardName)); + if (totalStampsRequired < 1) + throw new ArgumentException("Total stamps required must be at least 1 / Tong stamp can it nhat la 1", nameof(totalStampsRequired)); + + Id = Guid.NewGuid(); + _shopId = shopId; + _memberId = memberId; + _cardName = cardName; + _totalStampsRequired = totalStampsRequired; + _currentStamps = 0; + _isCompleted = false; + _rewardClaimed = false; + _createdAt = DateTime.UtcNow; + + // EN: Raise domain event for stamp card creation + // VI: Phat domain event khi tao stamp card + AddDomainEvent(new StampCardCreatedDomainEvent(this)); + } + + /// + /// EN: Add a stamp to the card. If total stamps reached, mark as completed. + /// VI: Them stamp vao card. Neu dat du stamp, danh dau hoan thanh. + /// + public void AddStamp() + { + if (_isCompleted) + throw new InvalidOperationException("Cannot add stamp to a completed card / Khong the them stamp vao card da hoan thanh"); + + _currentStamps++; + + // EN: Raise stamp added event + // VI: Phat event khi them stamp + AddDomainEvent(new StampAddedDomainEvent(this)); + + // EN: Check if card is now complete + // VI: Kiem tra card da hoan thanh chua + if (_currentStamps >= _totalStampsRequired) + { + _isCompleted = true; + _completedAt = DateTime.UtcNow; + + // EN: Raise card completed event for notifications / rewards + // VI: Phat event hoan thanh card cho thong bao / phan thuong + AddDomainEvent(new StampCardCompletedDomainEvent(this)); + } + } + + /// + /// EN: Claim the reward after card completion. + /// VI: Nhan thuong sau khi hoan thanh card. + /// + public void ClaimReward() + { + if (!_isCompleted) + throw new InvalidOperationException("Cannot claim reward on an incomplete card / Khong the nhan thuong tren card chua hoan thanh"); + if (_rewardClaimed) + throw new InvalidOperationException("Reward already claimed / Phan thuong da duoc nhan roi"); + + _rewardClaimed = true; + + // EN: Raise reward claimed event + // VI: Phat event khi nhan thuong + AddDomainEvent(new RewardClaimedDomainEvent(this)); + } + + /// + /// EN: Reset the stamp card for a new round (after reward has been claimed). + /// VI: Reset stamp card cho vong moi (sau khi da nhan thuong). + /// + public void Reset() + { + if (!_rewardClaimed) + throw new InvalidOperationException("Cannot reset card before reward is claimed / Khong the reset card truoc khi nhan thuong"); + + _currentStamps = 0; + _isCompleted = false; + _rewardClaimed = false; + _completedAt = null; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/RewardClaimedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/RewardClaimedDomainEvent.cs new file mode 100644 index 00000000..1930e89a --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/RewardClaimedDomainEvent.cs @@ -0,0 +1,10 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when a reward is claimed on a completed stamp card. +/// VI: Domain event phat ra khi nhan thuong tren stamp card da hoan thanh. +/// +public record RewardClaimedDomainEvent(StampCard StampCard) : INotification; diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/StampAddedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/StampAddedDomainEvent.cs new file mode 100644 index 00000000..52e7b7f6 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/StampAddedDomainEvent.cs @@ -0,0 +1,10 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when a stamp is added to a card. +/// VI: Domain event phat ra khi stamp duoc them vao card. +/// +public record StampAddedDomainEvent(StampCard StampCard) : INotification; diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/StampCardCompletedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/StampCardCompletedDomainEvent.cs new file mode 100644 index 00000000..8c7f145c --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/StampCardCompletedDomainEvent.cs @@ -0,0 +1,10 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when a stamp card is completed (all stamps collected). +/// VI: Domain event phat ra khi stamp card hoan thanh (da thu thap du stamp). +/// +public record StampCardCompletedDomainEvent(StampCard StampCard) : INotification; diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/StampCardCreatedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/StampCardCreatedDomainEvent.cs new file mode 100644 index 00000000..a02b2f08 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/StampCardCreatedDomainEvent.cs @@ -0,0 +1,10 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when a new stamp card is created. +/// VI: Domain event phat ra khi stamp card moi duoc tao. +/// +public record StampCardCreatedDomainEvent(StampCard StampCard) : INotification; diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs index ee856b90..fb8cae80 100644 --- a/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs +++ b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using MembershipService.Domain.AggregatesModel.ExperienceAggregate; using MembershipService.Domain.AggregatesModel.LevelAggregate; using MembershipService.Domain.AggregatesModel.MemberAggregate; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; using MembershipService.Infrastructure.ExternalServices; using MembershipService.Infrastructure.Idempotency; using MembershipService.Infrastructure.Repositories; @@ -53,6 +54,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/StampCardEntityTypeConfiguration.cs b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/StampCardEntityTypeConfiguration.cs new file mode 100644 index 00000000..8a23d11b --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/StampCardEntityTypeConfiguration.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; + +namespace MembershipService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for StampCard aggregate. +/// VI: Cau hinh entity cho StampCard aggregate. +/// +public class StampCardEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stamp_cards"); + + // EN: Primary key + // VI: Primary key + builder.HasKey(sc => sc.Id); + + builder.Property(sc => sc.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Shop ID + // VI: ID shop + builder.Property("_shopId") + .HasColumnName("shop_id") + .IsRequired(); + + // EN: Member ID + // VI: ID member + builder.Property("_memberId") + .HasColumnName("member_id") + .IsRequired(); + + // EN: Card name + // VI: Ten card + builder.Property("_cardName") + .HasColumnName("card_name") + .HasMaxLength(200) + .IsRequired(); + + // EN: Total stamps required for reward + // VI: Tong stamp can cho thuong + builder.Property("_totalStampsRequired") + .HasColumnName("total_stamps_required") + .IsRequired() + .HasDefaultValue(10); + + // EN: Current stamps collected + // VI: So stamp hien tai da thu thap + builder.Property("_currentStamps") + .HasColumnName("current_stamps") + .IsRequired() + .HasDefaultValue(0); + + // EN: Whether card is completed + // VI: Card da hoan thanh chua + builder.Property("_isCompleted") + .HasColumnName("is_completed") + .IsRequired() + .HasDefaultValue(false); + + // EN: Whether reward has been claimed + // VI: Thuong da duoc nhan chua + builder.Property("_rewardClaimed") + .HasColumnName("reward_claimed") + .IsRequired() + .HasDefaultValue(false); + + // EN: Timestamps + // VI: Timestamps + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_completedAt") + .HasColumnName("completed_at"); + + // EN: Ignore domain events (not persisted) + // VI: Bo qua domain events (khong luu) + builder.Ignore(sc => sc.DomainEvents); + + // EN: Indexes for common queries + // VI: Indexes cho cac query thuong dung + + // EN: Composite index for member+shop lookup (most common query) + // VI: Composite index cho tra cuu member+shop (query thuong dung nhat) + builder.HasIndex("_shopId", "_memberId") + .HasDatabaseName("ix_stamp_cards_shop_member"); + + // EN: Index for shop-level admin queries + // VI: Index cho cac query admin cap shop + builder.HasIndex("_shopId") + .HasDatabaseName("ix_stamp_cards_shop_id"); + + // EN: Index for filtering by completion status + // VI: Index cho loc theo trang thai hoan thanh + builder.HasIndex("_isCompleted") + .HasDatabaseName("ix_stamp_cards_is_completed"); + + builder.HasIndex("_createdAt") + .HasDatabaseName("ix_stamp_cards_created_at"); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs index a5e048fb..eaf801e3 100644 --- a/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs +++ b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Storage; using MembershipService.Domain.AggregatesModel.ExperienceAggregate; using MembershipService.Domain.AggregatesModel.LevelAggregate; using MembershipService.Domain.AggregatesModel.MemberAggregate; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; using MembershipService.Domain.SeedWork; namespace MembershipService.Infrastructure; @@ -53,6 +54,12 @@ public class MembershipServiceContext : DbContext, IUnitOfWork /// public DbSet ExperienceTransactions { get; set; } = null!; + /// + /// EN: Stamp cards table. + /// VI: Bang stamp cards. + /// + public DbSet StampCards { get; set; } = null!; + /// /// EN: Check if there's an active transaction. /// VI: Kiểm tra xem có transaction đang hoạt động không. diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/StampCardRepository.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/StampCardRepository.cs new file mode 100644 index 00000000..42467f8a --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/StampCardRepository.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using MembershipService.Domain.AggregatesModel.StampCardAggregate; +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for StampCard aggregate. +/// VI: Repository implementation cho StampCard aggregate. +/// +public class StampCardRepository : IStampCardRepository +{ + private readonly MembershipServiceContext _context; + + public StampCardRepository(MembershipServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + /// EN: Get stamp card by ID. + /// VI: Lay stamp card theo ID. + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.StampCards + .FirstOrDefaultAsync(sc => sc.Id == id, cancellationToken); + } + + /// + /// EN: Get active stamp card (not completed, or completed but not claimed) for member+shop. + /// VI: Lay stamp card dang hoat dong (chua hoan thanh, hoac hoan thanh nhung chua nhan thuong) cho member+shop. + /// + public async Task GetActiveCardAsync(Guid shopId, Guid memberId, CancellationToken cancellationToken = default) + { + return await _context.StampCards + .Where(sc => EF.Property(sc, "_shopId") == shopId + && EF.Property(sc, "_memberId") == memberId + && !EF.Property(sc, "_rewardClaimed")) + .OrderByDescending(sc => EF.Property(sc, "_createdAt")) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + /// EN: Get stamp card by member and shop (most recent). + /// VI: Lay stamp card theo member va shop (moi nhat). + /// + public async Task GetByMemberAndShopAsync(Guid shopId, Guid memberId, CancellationToken cancellationToken = default) + { + return await _context.StampCards + .Where(sc => EF.Property(sc, "_shopId") == shopId + && EF.Property(sc, "_memberId") == memberId) + .OrderByDescending(sc => EF.Property(sc, "_createdAt")) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + /// EN: Get all stamp cards for a shop (admin view, paginated). + /// VI: Lay tat ca stamp cards cho shop (admin view, phan trang). + /// + public async Task<(IEnumerable Cards, int TotalCount)> GetByShopAsync( + Guid shopId, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + var query = _context.StampCards + .Where(sc => EF.Property(sc, "_shopId") == shopId); + + var totalCount = await query.CountAsync(cancellationToken); + + var cards = await query + .OrderByDescending(sc => EF.Property(sc, "_createdAt")) + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (cards, totalCount); + } + + /// + /// EN: Add a new stamp card. + /// VI: Them stamp card moi. + /// + public StampCard Add(StampCard stampCard) + { + return _context.StampCards.Add(stampCard).Entity; + } + + /// + /// EN: Update an existing stamp card. + /// VI: Cap nhat stamp card hien tai. + /// + public void Update(StampCard stampCard) + { + _context.Entry(stampCard).State = EntityState.Modified; + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommand.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommand.cs new file mode 100644 index 00000000..299859cb --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommand.cs @@ -0,0 +1,18 @@ +// EN: Command to create an exchange (return + new order in one transaction). +// VI: Command để tạo đổi hàng (trả hàng + đơn mới trong một transaction). + +using MediatR; +using OrderService.API.Application.DTOs; + +namespace OrderService.API.Application.Commands; + +/// +/// EN: Command to process exchange — return items from original order and create new order with replacement items. +/// VI: Command để xử lý đổi hàng — trả items từ đơn gốc và tạo đơn mới với items thay thế. +/// +public record CreateExchangeCommand( + Guid ShopId, + Guid OriginalOrderId, + List ReturnItems, + List NewItems, + string Reason) : IRequest; diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommandHandler.cs new file mode 100644 index 00000000..529c61d9 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateExchangeCommandHandler.cs @@ -0,0 +1,137 @@ +// EN: Handler for CreateExchangeCommand. +// VI: Handler cho CreateExchangeCommand. + +using MediatR; +using OrderService.API.Application.DTOs; +using OrderService.Domain.AggregatesModel.OrderAggregate; +using OrderService.Domain.Events; + +namespace OrderService.API.Application.Commands; + +/// +/// EN: Handler for exchange — processes return + creates new order in same transaction. +/// VI: Handler cho đổi hàng — xử lý trả hàng + tạo đơn mới trong cùng transaction. +/// +public class CreateExchangeCommandHandler : IRequestHandler +{ + private readonly IOrderRepository _orderRepository; + private readonly ILogger _logger; + + public CreateExchangeCommandHandler( + IOrderRepository orderRepository, + ILogger logger) + { + _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CreateExchangeCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Processing exchange for order {OriginalOrderId} in shop {ShopId} / VI: Xử lý đổi hàng cho đơn {OriginalOrderId} trong shop {ShopId}", + request.OriginalOrderId, request.ShopId); + + // EN: Validate original order + // VI: Xác thực đơn hàng gốc + var originalOrder = await _orderRepository.GetByIdAsync(request.OriginalOrderId, cancellationToken); + + if (originalOrder == null) + { + return new CreateExchangeResult(false, null, null, null, + $"Original order {request.OriginalOrderId} not found / Đơn hàng gốc {request.OriginalOrderId} không tìm thấy"); + } + + if (originalOrder.ShopId != request.ShopId) + { + return new CreateExchangeResult(false, null, null, null, + "Order does not belong to this shop / Đơn hàng không thuộc shop này"); + } + + if (originalOrder.Status != OrderStatus.Completed && originalOrder.Status != OrderStatus.Paid) + { + return new CreateExchangeResult(false, null, null, null, + $"Cannot exchange order with status {originalOrder.Status.Name} / Không thể đổi hàng cho đơn có trạng thái {originalOrder.Status.Name}"); + } + + var originalItemsMap = originalOrder.Items.ToDictionary(i => i.Id); + + // EN: Step 1: Create return order + // VI: Bước 1: Tạo đơn trả hàng + var returnOrder = new Order(request.ShopId, originalOrder.CustomerId); + decimal returnAmount = 0; + + foreach (var returnItem in request.ReturnItems) + { + if (!originalItemsMap.TryGetValue(returnItem.OrderItemId, out var originalItem)) + { + return new CreateExchangeResult(false, null, null, null, + $"Order item {returnItem.OrderItemId} not found in original order / Item {returnItem.OrderItemId} không tìm thấy trong đơn gốc"); + } + + if (returnItem.Quantity <= 0 || returnItem.Quantity > originalItem.Quantity) + { + return new CreateExchangeResult(false, null, null, null, + $"Invalid return quantity for item {returnItem.OrderItemId} / Số lượng trả không hợp lệ cho item {returnItem.OrderItemId}"); + } + + var orderItem = new OrderItem( + originalItem.ProductId, + originalItem.ProductName, + originalItem.ProductType, + returnItem.Quantity, + originalItem.UnitPrice, + metadata: returnItem.Reason, + trackInventory: originalItem.TrackInventory); + + returnOrder.AddItem(orderItem); + returnAmount += returnItem.Quantity * originalItem.UnitPrice; + } + + returnOrder.MarkAsValidated(); + returnOrder.ProcessReturn(request.Reason, request.OriginalOrderId); + + // EN: Step 2: Create new order with replacement items + // VI: Bước 2: Tạo đơn mới với items thay thế + var newOrder = new Order(request.ShopId, originalOrder.CustomerId); + decimal newOrderAmount = 0; + + foreach (var newItem in request.NewItems) + { + // EN: Product name will need to be passed from frontend or resolved via cross-service call. + // For now, use "Exchange Item" as placeholder — frontend should pass full product info. + // VI: Tên sản phẩm cần được truyền từ frontend hoặc resolve qua cross-service call. + // Tạm thời dùng "Exchange Item" — frontend nên truyền đầy đủ thông tin sản phẩm. + var orderItem = new OrderItem( + newItem.ProductId, + "Exchange Item", // EN: Should be resolved / VI: Cần được resolve + "Physical", + newItem.Quantity, + newItem.UnitPrice, + trackInventory: true); + + newOrder.AddItem(orderItem); + newOrderAmount += newItem.Quantity * newItem.UnitPrice; + } + + newOrder.MarkAsValidated(); + + // EN: Calculate price difference (positive = customer pays more, negative = refund) + // VI: Tính chênh lệch giá (dương = khách trả thêm, âm = hoàn tiền) + decimal priceDifference = newOrderAmount - returnAmount; + + _orderRepository.Add(returnOrder); + _orderRepository.Add(newOrder); + + // EN: Raise exchange domain event + // VI: Phát ra domain event đổi hàng + returnOrder.AddDomainEvent(new OrderExchangedDomainEvent(returnOrder, newOrder)); + + await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Exchange processed — Return: {ReturnId}, New: {NewId}, Difference: {Diff} / VI: Đổi hàng đã xử lý — Trả: {ReturnId}, Mới: {NewId}, Chênh lệch: {Diff}", + returnOrder.Id, newOrder.Id, priceDifference); + + return new CreateExchangeResult(true, returnOrder.Id, newOrder.Id, priceDifference, null); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommand.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommand.cs new file mode 100644 index 00000000..cf77c58c --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommand.cs @@ -0,0 +1,17 @@ +// EN: Command to create a return order. +// VI: Command để tạo đơn trả hàng. + +using MediatR; +using OrderService.API.Application.DTOs; + +namespace OrderService.API.Application.Commands; + +/// +/// EN: Command to create a return order for a completed order. +/// VI: Command để tạo đơn trả hàng cho đơn hàng đã hoàn thành. +/// +public record CreateReturnCommand( + Guid ShopId, + Guid OriginalOrderId, + List Items, + string Reason) : IRequest; diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommandHandler.cs new file mode 100644 index 00000000..ae787db0 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateReturnCommandHandler.cs @@ -0,0 +1,108 @@ +// EN: Handler for CreateReturnCommand. +// VI: Handler cho CreateReturnCommand. + +using MediatR; +using OrderService.API.Application.DTOs; +using OrderService.Domain.AggregatesModel.OrderAggregate; + +namespace OrderService.API.Application.Commands; + +/// +/// EN: Handler for creating a return order — validates original order, creates return with negative amounts, restores inventory. +/// VI: Handler cho tạo đơn trả hàng — xác thực đơn gốc, tạo đơn trả với số tiền âm, khôi phục inventory. +/// +public class CreateReturnCommandHandler : IRequestHandler +{ + private readonly IOrderRepository _orderRepository; + private readonly ILogger _logger; + + public CreateReturnCommandHandler( + IOrderRepository orderRepository, + ILogger logger) + { + _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CreateReturnCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Processing return for order {OriginalOrderId} in shop {ShopId} / VI: Xử lý trả hàng cho đơn {OriginalOrderId} trong shop {ShopId}", + request.OriginalOrderId, request.ShopId); + + // EN: Validate original order exists and is completed + // VI: Xác thực đơn hàng gốc tồn tại và đã hoàn thành + var originalOrder = await _orderRepository.GetByIdAsync(request.OriginalOrderId, cancellationToken); + + if (originalOrder == null) + { + return new CreateReturnResult(false, null, null, + $"Original order {request.OriginalOrderId} not found / Đơn hàng gốc {request.OriginalOrderId} không tìm thấy"); + } + + if (originalOrder.ShopId != request.ShopId) + { + return new CreateReturnResult(false, null, null, + "Order does not belong to this shop / Đơn hàng không thuộc shop này"); + } + + if (originalOrder.Status != OrderStatus.Completed && originalOrder.Status != OrderStatus.Paid) + { + return new CreateReturnResult(false, null, null, + $"Cannot return order with status {originalOrder.Status.Name}. Order must be Completed or Paid / Không thể trả hàng cho đơn có trạng thái {originalOrder.Status.Name}. Đơn phải Hoàn thành hoặc Đã thanh toán"); + } + + // EN: Build a lookup of original order items + // VI: Xây dựng lookup cho items đơn hàng gốc + var originalItemsMap = originalOrder.Items.ToDictionary(i => i.Id); + + // EN: Create return order with negative amounts + // VI: Tạo đơn trả hàng với số tiền âm + var returnOrder = new Order(request.ShopId, originalOrder.CustomerId); + + decimal refundAmount = 0; + + foreach (var returnItem in request.Items) + { + if (!originalItemsMap.TryGetValue(returnItem.OrderItemId, out var originalItem)) + { + return new CreateReturnResult(false, null, null, + $"Order item {returnItem.OrderItemId} not found in original order / Item {returnItem.OrderItemId} không tìm thấy trong đơn hàng gốc"); + } + + if (returnItem.Quantity <= 0 || returnItem.Quantity > originalItem.Quantity) + { + return new CreateReturnResult(false, null, null, + $"Invalid return quantity for item {returnItem.OrderItemId}. Max: {originalItem.Quantity} / Số lượng trả không hợp lệ cho item {returnItem.OrderItemId}. Tối đa: {originalItem.Quantity}"); + } + + // EN: Create return item with negative unit price for refund calculation + // VI: Tạo return item với đơn giá âm cho tính toán hoàn tiền + var orderItem = new OrderItem( + originalItem.ProductId, + originalItem.ProductName, + originalItem.ProductType, + returnItem.Quantity, + originalItem.UnitPrice, + metadata: returnItem.Reason, + trackInventory: originalItem.TrackInventory); + + returnOrder.AddItem(orderItem); + refundAmount += returnItem.Quantity * originalItem.UnitPrice; + } + + // EN: Mark as return and process + // VI: Đánh dấu là đơn trả hàng và xử lý + returnOrder.MarkAsValidated(); + returnOrder.ProcessReturn(request.Reason, request.OriginalOrderId); + + _orderRepository.Add(returnOrder); + await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Return order {ReturnOrderId} created for original order {OriginalOrderId}, refund: {RefundAmount} / VI: Đơn trả hàng {ReturnOrderId} đã tạo cho đơn gốc {OriginalOrderId}, hoàn tiền: {RefundAmount}", + returnOrder.Id, request.OriginalOrderId, refundAmount); + + return new CreateReturnResult(true, returnOrder.Id, refundAmount, null); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs b/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs index 8e28952d..2a1a9b59 100644 --- a/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs +++ b/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs @@ -82,3 +82,49 @@ public record PagedResult( /// public bool HasPreviousPage => CurrentPage > 1; } + +/// +/// EN: DTO for return item in a return/exchange request. +/// VI: DTO cho item trả hàng trong yêu cầu trả/đổi hàng. +/// +public record ReturnItemDto(Guid OrderItemId, int Quantity, string? Reason); + +/// +/// EN: DTO for exchange item in an exchange request (new items to receive). +/// VI: DTO cho item đổi hàng trong yêu cầu đổi hàng (item mới nhận về). +/// +public record ExchangeItemDto(Guid ProductId, int Quantity, decimal UnitPrice); + +/// +/// EN: Return order details DTO. +/// VI: DTO chi tiết đơn trả hàng. +/// +public record ReturnOrderDto( + Guid Id, + Guid OriginalOrderId, + string Reason, + decimal RefundAmount, + string Status, + DateTime ReturnedAt, + List Items); + +/// +/// EN: Result for create return command. +/// VI: Kết quả cho command tạo trả hàng. +/// +public record CreateReturnResult( + bool Success, + Guid? ReturnOrderId, + decimal? RefundAmount, + string? ErrorMessage); + +/// +/// EN: Result for create exchange command. +/// VI: Kết quả cho command tạo đổi hàng. +/// +public record CreateExchangeResult( + bool Success, + Guid? ReturnOrderId, + Guid? NewOrderId, + decimal? PriceDifference, + string? ErrorMessage); diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQuery.cs new file mode 100644 index 00000000..4554b7d6 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQuery.cs @@ -0,0 +1,13 @@ +// EN: Query to get return history for an order. +// VI: Query để lấy lịch sử trả hàng cho đơn hàng. + +using MediatR; +using OrderService.API.Application.DTOs; + +namespace OrderService.API.Application.Queries; + +/// +/// EN: Query to get all return orders associated with an original order. +/// VI: Query để lấy tất cả đơn trả hàng liên kết với đơn hàng gốc. +/// +public record GetOrderReturnsQuery(Guid OrderId) : IRequest>; diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQueryHandler.cs b/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQueryHandler.cs new file mode 100644 index 00000000..94a0a5cc --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderReturnsQueryHandler.cs @@ -0,0 +1,54 @@ +// EN: Handler for GetOrderReturnsQuery. +// VI: Handler cho GetOrderReturnsQuery. + +using MediatR; +using OrderService.API.Application.DTOs; +using OrderService.Domain.AggregatesModel.OrderAggregate; + +namespace OrderService.API.Application.Queries; + +/// +/// EN: Handler for querying return history of an order. +/// VI: Handler cho query lịch sử trả hàng của đơn hàng. +/// +public class GetOrderReturnsQueryHandler : IRequestHandler> +{ + private readonly IOrderRepository _orderRepository; + private readonly ILogger _logger; + + public GetOrderReturnsQueryHandler( + IOrderRepository orderRepository, + ILogger logger) + { + _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> Handle(GetOrderReturnsQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Getting return history for order {OrderId} / VI: Lấy lịch sử trả hàng cho đơn {OrderId}", + request.OrderId); + + var returns = await _orderRepository.GetReturnsByOriginalOrderIdAsync( + request.OrderId, cancellationToken); + + return returns.Select(r => new ReturnOrderDto( + r.Id, + request.OrderId, + r.ReturnReason ?? string.Empty, + r.TotalAmount, + r.Status.Name, + r.ReturnedAt ?? r.CreatedAt, + r.Items.Select(i => new OrderItemDto( + i.Id, + i.ProductId, + i.ProductName, + i.ProductType, + i.Quantity, + i.UnitPrice, + i.TotalPrice, + i.Status)).ToList() + )).ToList(); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CreateExchangeCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CreateExchangeCommandValidator.cs new file mode 100644 index 00000000..ff92f37c --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/CreateExchangeCommandValidator.cs @@ -0,0 +1,66 @@ +// EN: Validator for CreateExchangeCommand. +// VI: Validator cho CreateExchangeCommand. + +using FluentValidation; +using OrderService.API.Application.Commands; +using OrderService.API.Application.DTOs; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for CreateExchangeCommand. +/// VI: Validator cho CreateExchangeCommand. +/// +public class CreateExchangeCommandValidator : AbstractValidator +{ + public CreateExchangeCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("EN: Shop ID is required / VI: Shop ID là bắt buộc"); + + RuleFor(x => x.OriginalOrderId) + .NotEmpty() + .WithMessage("EN: Original order ID is required / VI: ID đơn hàng gốc là bắt buộc"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("EN: Exchange reason is required / VI: Lý do đổi hàng là bắt buộc") + .MaximumLength(1000) + .WithMessage("EN: Exchange reason must not exceed 1000 characters / VI: Lý do đổi hàng không được vượt quá 1000 ký tự"); + + RuleFor(x => x.ReturnItems) + .NotEmpty() + .WithMessage("EN: At least one return item is required / VI: Cần ít nhất một item trả hàng"); + + RuleForEach(x => x.ReturnItems).SetValidator(new ReturnItemValidator()); + + RuleFor(x => x.NewItems) + .NotEmpty() + .WithMessage("EN: At least one exchange item is required / VI: Cần ít nhất một item đổi hàng"); + + RuleForEach(x => x.NewItems).SetValidator(new ExchangeItemValidator()); + } +} + +/// +/// EN: Validator for ExchangeItemDto. +/// VI: Validator cho ExchangeItemDto. +/// +public class ExchangeItemValidator : AbstractValidator +{ + public ExchangeItemValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("EN: Product ID is required / VI: Product ID là bắt buộc"); + + RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("EN: Quantity must be greater than 0 / VI: Số lượng phải lớn hơn 0"); + + RuleFor(x => x.UnitPrice) + .GreaterThanOrEqualTo(0) + .WithMessage("EN: Unit price must be greater than or equal to 0 / VI: Đơn giá phải lớn hơn hoặc bằng 0"); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CreateReturnCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CreateReturnCommandValidator.cs new file mode 100644 index 00000000..ef031d17 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/CreateReturnCommandValidator.cs @@ -0,0 +1,63 @@ +// EN: Validator for CreateReturnCommand. +// VI: Validator cho CreateReturnCommand. + +using FluentValidation; +using OrderService.API.Application.Commands; +using OrderService.API.Application.DTOs; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for CreateReturnCommand. +/// VI: Validator cho CreateReturnCommand. +/// +public class CreateReturnCommandValidator : AbstractValidator +{ + public CreateReturnCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("EN: Shop ID is required / VI: Shop ID là bắt buộc"); + + RuleFor(x => x.OriginalOrderId) + .NotEmpty() + .WithMessage("EN: Original order ID is required / VI: ID đơn hàng gốc là bắt buộc"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("EN: Return reason is required / VI: Lý do trả hàng là bắt buộc") + .MaximumLength(1000) + .WithMessage("EN: Return reason must not exceed 1000 characters / VI: Lý do trả hàng không được vượt quá 1000 ký tự"); + + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage("EN: At least one return item is required / VI: Cần ít nhất một item trả hàng") + .Must(items => items != null && items.Count > 0) + .WithMessage("EN: At least one return item is required / VI: Cần ít nhất một item trả hàng"); + + RuleForEach(x => x.Items).SetValidator(new ReturnItemValidator()); + } +} + +/// +/// EN: Validator for ReturnItemDto. +/// VI: Validator cho ReturnItemDto. +/// +public class ReturnItemValidator : AbstractValidator +{ + public ReturnItemValidator() + { + RuleFor(x => x.OrderItemId) + .NotEmpty() + .WithMessage("EN: Order item ID is required / VI: ID item đơn hàng là bắt buộc"); + + RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("EN: Return quantity must be greater than 0 / VI: Số lượng trả phải lớn hơn 0"); + + RuleFor(x => x.Reason) + .MaximumLength(500) + .WithMessage("EN: Item reason must not exceed 500 characters / VI: Lý do item không được vượt quá 500 ký tự") + .When(x => x.Reason != null); + } +} diff --git a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs index 90dbd4fc..3413cb06 100644 --- a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs +++ b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs @@ -253,6 +253,71 @@ public class OrdersController : ControllerBase return Ok(result); } + /// + /// EN: Process a return for a completed order. + /// VI: Xử lý trả hàng cho đơn hàng đã hoàn thành. + /// + [HttpPost("returns")] + [ProducesResponseType(typeof(CreateReturnResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateReturn( + [FromBody] CreateReturnCommand command, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Creating return for order {OriginalOrderId} in shop {ShopId} / VI: Tạo trả hàng cho đơn {OriginalOrderId} trong shop {ShopId}", + command.OriginalOrderId, command.ShopId); + + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(new { success = false, error = new { code = "RETURN_FAILED", message = result.ErrorMessage } }); + } + + return Created($"/api/v1/orders/{result.ReturnOrderId}", new { success = true, data = result }); + } + + /// + /// EN: Process an exchange (return + new order). + /// VI: Xử lý đổi hàng (trả hàng + đơn mới). + /// + [HttpPost("exchanges")] + [ProducesResponseType(typeof(CreateExchangeResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateExchange( + [FromBody] CreateExchangeCommand command, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Creating exchange for order {OriginalOrderId} in shop {ShopId} / VI: Tạo đổi hàng cho đơn {OriginalOrderId} trong shop {ShopId}", + command.OriginalOrderId, command.ShopId); + + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(new { success = false, error = new { code = "EXCHANGE_FAILED", message = result.ErrorMessage } }); + } + + return Created($"/api/v1/orders/{result.NewOrderId}", new { success = true, data = result }); + } + + /// + /// EN: Get return history for an order. + /// VI: Lấy lịch sử trả hàng cho đơn hàng. + /// + [HttpGet("{orderId}/returns")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetOrderReturns( + Guid orderId, + CancellationToken cancellationToken = default) + { + var query = new GetOrderReturnsQuery(orderId); + var result = await _mediator.Send(query, cancellationToken); + return Ok(new { success = true, data = result }); + } + /// /// EN: Get orders by customer. /// VI: Lấy orders theo khách hàng. diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/IOrderRepository.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/IOrderRepository.cs index 2cb7c694..8b5b2279 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/IOrderRepository.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/IOrderRepository.cs @@ -40,4 +40,10 @@ public interface IOrderRepository : IRepository /// VI: Lấy danh sách đơn hàng theo customer ID. /// Task> GetByCustomerIdAsync(Guid customerId, CancellationToken cancellationToken = default); + + /// + /// EN: Get return orders for an original order. + /// VI: Lấy các đơn trả hàng cho đơn hàng gốc. + /// + Task> GetReturnsByOriginalOrderIdAsync(Guid originalOrderId, CancellationToken cancellationToken = default); } diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs index 863421ab..0e941b00 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs @@ -30,6 +30,11 @@ public class Order : Entity, IAggregateRoot private decimal? _amountTendered; private decimal? _changeAmount; + private string? _returnReason; + private DateTime? _returnedAt; + private bool _isReturn; + private Guid? _originalOrderId; + private readonly List _items = new(); /// @@ -102,6 +107,30 @@ public class Order : Entity, IAggregateRoot public IReadOnlyCollection Items => _items.AsReadOnly(); + /// + /// EN: Reason for return (if this is a return order). + /// VI: Lý do trả hàng (nếu đây là đơn trả hàng). + /// + public string? ReturnReason => _returnReason; + + /// + /// EN: Timestamp when return was processed. + /// VI: Thời gian xử lý trả hàng. + /// + public DateTime? ReturnedAt => _returnedAt; + + /// + /// EN: Whether this order is a return order. + /// VI: Đây có phải là đơn trả hàng không. + /// + public bool IsReturn => _isReturn; + + /// + /// EN: Original order ID (for return/exchange orders). + /// VI: ID đơn hàng gốc (cho đơn trả hàng/đổi hàng). + /// + public Guid? OriginalOrderId => _originalOrderId; + /// /// EN: Creation timestamp. /// VI: Thời gian tạo. @@ -278,6 +307,26 @@ public class Order : Entity, IAggregateRoot AddDomainEvent(new OrderCancelledDomainEvent(this, reason)); } + /// + /// EN: Process return — marks the order as a return order with reason and original order reference. + /// VI: Xử lý trả hàng — đánh dấu đơn hàng là đơn trả hàng với lý do và tham chiếu đơn gốc. + /// + public void ProcessReturn(string reason, Guid? originalOrderId) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new DomainException("Return reason is required / Lý do trả hàng là bắt buộc"); + + _isReturn = true; + _returnReason = reason; + _originalOrderId = originalOrderId; + _returnedAt = DateTime.UtcNow; + _status = OrderStatus.Returned; + StatusId = OrderStatus.Returned.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new OrderReturnedDomainEvent(this)); + } + /// /// EN: Apply discount to order. /// VI: Áp dụng giảm giá cho đơn hàng. diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs index 195236b2..0f669177 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs @@ -53,6 +53,12 @@ public class OrderStatus : Enumeration /// public static readonly OrderStatus PaymentPending = new(7, nameof(PaymentPending)); + /// + /// EN: Order returned — items returned by customer. + /// VI: Đơn hàng đã trả — hàng đã được khách trả lại. + /// + public static readonly OrderStatus Returned = new(8, nameof(Returned)); + public OrderStatus(int id, string name) : base(id, name) { } diff --git a/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs b/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs index c378ec7d..670163b1 100644 --- a/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs +++ b/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs @@ -35,3 +35,15 @@ public record OrderPaymentPendingDomainEvent(Order Order) : INotification; /// VI: Domain event phát ra khi đơn hàng bị hủy. /// public record OrderCancelledDomainEvent(Order Order, string Reason) : INotification; + +/// +/// EN: Domain event raised when an order is returned. +/// VI: Domain event phát ra khi đơn hàng được trả lại. +/// +public record OrderReturnedDomainEvent(Order Order) : INotification; + +/// +/// EN: Domain event raised when an exchange is processed (return + new order). +/// VI: Domain event phát ra khi đổi hàng được xử lý (trả hàng + đơn mới). +/// +public record OrderExchangedDomainEvent(Order ReturnOrder, Order NewOrder) : INotification; diff --git a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs index 844a8054..0d09a9e8 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs @@ -81,6 +81,20 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration .HasColumnName("change_amount") .HasColumnType("decimal(18,2)"); + builder.Property("_returnReason") + .HasColumnName("return_reason") + .HasMaxLength(1000); + + builder.Property("_returnedAt") + .HasColumnName("returned_at"); + + builder.Property("_isReturn") + .HasColumnName("is_return") + .HasDefaultValue(false); + + builder.Property("_originalOrderId") + .HasColumnName("original_order_id"); + // EN: OrderItems collection // VI: Collection OrderItems builder.OwnsMany(o => o.Items, orderItems => @@ -152,6 +166,7 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration builder.HasIndex("_customerId").HasDatabaseName("ix_orders_customer_id"); builder.HasIndex(o => o.StatusId).HasDatabaseName("ix_orders_status_id"); builder.HasIndex("_createdAt").HasDatabaseName("ix_orders_created_at"); + builder.HasIndex("_originalOrderId").HasDatabaseName("ix_orders_original_order_id"); // EN: Ignore calculated properties // VI: Bỏ qua các properties được tính toán @@ -169,5 +184,9 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration builder.Ignore(o => o.ChangeAmount); builder.Ignore(o => o.CreatedAt); builder.Ignore(o => o.UpdatedAt); + builder.Ignore(o => o.ReturnReason); + builder.Ignore(o => o.ReturnedAt); + builder.Ignore(o => o.IsReturn); + builder.Ignore(o => o.OriginalOrderId); } } diff --git a/services/order-service-net/src/OrderService.Infrastructure/Repositories/OrderRepository.cs b/services/order-service-net/src/OrderService.Infrastructure/Repositories/OrderRepository.cs index 574c144a..b8a04a71 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/Repositories/OrderRepository.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/Repositories/OrderRepository.cs @@ -53,4 +53,16 @@ public class OrderRepository : IOrderRepository .OrderByDescending(o => o.CreatedAt) .ToListAsync(cancellationToken); } + + /// + /// EN: Get return orders for an original order. + /// VI: Lấy các đơn trả hàng cho đơn hàng gốc. + /// + public async Task> GetReturnsByOriginalOrderIdAsync(Guid originalOrderId, CancellationToken cancellationToken = default) + { + return await _context.Orders + .Where(o => o.OriginalOrderId == originalOrderId && o.IsReturn) + .OrderByDescending(o => o.CreatedAt) + .ToListAsync(cancellationToken); + } }