diff --git a/ROADMAP.md b/ROADMAP.md index 20cd4c87..db47ff1c 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) | ~40% | 70% | 80% | 85% | +| Test coverage (estimated) | ~45% | 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 | @@ -109,17 +109,18 @@ | 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 | ### P2 — Enhancement | # | Gap | Status | Sprint | Notes | |:-:|-----|:------:|:------:|-------| -| 14 | Marketing — Zalo OA | `TODO` | Phase 3 | mkt-zalo-service | -| 15 | Marketing — Facebook | `TODO` | Phase 3 | mkt-facebook-service | -| 16 | Ads Platform | `TODO` | Phase 3 | 5 ads services | -| 17 | Mobile iOS v1 | `TODO` | Phase 3 | app-client-base-swift | -| 18 | Mobile MAUI v1 | `TODO` | Phase 3 | app-client-base-net | -| 19 | Observability Stack | `TODO` | Phase 2 | Prometheus + Grafana + Loki | +| 15 | Marketing — Zalo OA | `TODO` | Phase 3 | mkt-zalo-service | +| 16 | Marketing — Facebook | `TODO` | Phase 3 | mkt-facebook-service | +| 17 | Ads Platform | `TODO` | Phase 3 | 5 ads services | +| 18 | Mobile iOS v1 | `TODO` | Phase 3 | app-client-base-swift | +| 19 | Mobile MAUI v1 | `TODO` | Phase 3 | app-client-base-net | +| 20 | Observability Stack | `TODO` | Phase 2 | Prometheus + Grafana + Loki | --- @@ -146,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 | `TODO` | — | -| Input sanitization audit | QA | `TODO` | — | +| Rate limiting audit | DevOps | `IN-PROGRESS` | — | +| Input sanitization audit | QA | `IN-PROGRESS` | — | | FnB Engine unit tests | QA | `DONE` | — | | Order lifecycle integration tests | QA | `DONE` | 29 tests, WebApplicationFactory | @@ -155,7 +156,7 @@ | Task | Agent | Status | Depends On | |------|-------|:------:|:----------:| -| EOD reports + daily close workflow | Senior Frontend | `TODO` | order-service | +| EOD reports + daily close workflow | Senior Frontend | `IN-PROGRESS` | 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 | @@ -235,6 +236,24 @@ | Traefik Route (subscriptions) | DevOps | /api/v1/subscriptions → merchant-service | | Admin Settings 5-Tab UI | Frontend | Tai khoan, Bao mat, Goi dich vu, Thong bao, He thong | +### 2026-03-06 (Code Review Fixes) + +| Task | Agent | Details | +|------|-------|---------| +| Code Review — 75 issues identified | All Agents | Backend (16), Frontend (11), Infrastructure (32), Tests (16) | +| Fix wallet-service EF Config | Backend #1 | Removed 11 conflicting Ignore() calls for mapped backing fields | +| Fix KitchenTicket constructor | Backend #1 | Removed short constructor that assigned productId=orderItemId, updated 14 test call sites | +| Fix fire-and-forget inventory deduction | Backend #1 | Replaced Task.Run with direct await for reliable inventory deduction | +| Implement TenantMiddleware RLS (4 services) | Backend #2 | wallet, fnb, inventory, catalog — PostgreSQL SET LOCAL for RLS | +| Fix SQL injection pattern in order-service | Backend #2 | Guid.ToString("D") for safe formatting in TenantMiddleware | +| Add SignalR Hub shop authorization | Backend #2 | ValidateShopAccess() check in PosHub JoinShop/JoinKds/JoinPos | +| Fix PosDataService false success on error | Frontend | PayOrderWithDetailsAsync now returns Success=false on parse failure | +| Fix QrPayment timer race condition | Frontend | Added _disposed guard for safe timer disposal | +| Add [Authorize] to BFF OrderController | Frontend | Require JWT for all BFF order endpoints | +| PostgreSQL 15 → 16 in docker-compose | DevOps | Match project spec | +| 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-05 | Task | Agent | Details | @@ -252,7 +271,7 @@ |------|----------|-----------|:------:| | 2026-03-06 | IPaymentGateway in Domain, implementations in Infrastructure | Multiple gateways (VNPay, Momo) via same interface | ACTIVE | | 2026-03-06 | PosHub in order-service (not separate service) | Order lifecycle owns real-time notifications | ACTIVE | -| 2026-03-06 | Kitchen→Inventory via HTTP + Polly (not message queue) | Simpler, sufficient for MVP, fire-and-forget pattern | ACTIVE | +| 2026-03-06 | Kitchen→Inventory via HTTP + Polly (not message queue) | Simpler, sufficient for MVP, direct await (changed from fire-and-forget after code review) | ACTIVE | | 2026-03-06 | 3 payment flows: cash (instant), card (instant), online (async) | Cash/card don't need gateway, only VNPay/Momo need redirect | ACTIVE | | 2026-03-06 | Subscription stored in Merchant aggregate | Simple, no separate service needed for MVP | ACTIVE | | 2026-03-06 | Static plan definitions in frontend + backend | 4 fixed tiers sufficient for MVP launch | ACTIVE | diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/EodReport.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/EodReport.razor new file mode 100644 index 00000000..1429d609 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/EodReport.razor @@ -0,0 +1,463 @@ +@page "/admin/reports/eod" +@layout AdminLayout +@inherits AdminBase +@inject PosDataService DataService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@using WebClientTpos.Client.Services +@using WebClientTpos.Client.Pages.Admin.Shop + +@* + EN: End-of-Day Report page — daily closing summary with revenue, orders, payment breakdown, and top items. + VI: Trang bao cao cuoi ngay — tom tat dong ngay voi doanh thu, don hang, phan tich thanh toan, va san pham ban chay. +*@ + +Bao cao cuoi ngay — GoodGo POS + +@* ═══ TOP BAR ═══ *@ +
+
+

Bao cao cuoi ngay

+

Tong hop doanh thu va don hang trong ngay

+
+
+ + + @if (_loading) + { + + } + Xem bao cao + + + Dong ngay + +
+
+ +@* ═══ CONTENT ═══ *@ +
+ + @if (_loading) + { +
+ +
+ } + else if (_report == null) + { + + Chon ngay va nhan "Xem bao cao" de tai du lieu bao cao cuoi ngay. + + } + else + { + @* ── KPI SUMMARY CARDS ── *@ +
+ @* Total Revenue *@ +
+
+ +
+
+ @FormatVND(_report.TotalRevenue) + Tong doanh thu +
+
+ @* Total Orders *@ +
+
+ +
+
+ @_report.TotalOrders + Tong don hang +
+
+ @* Average Order Value *@ +
+
+ +
+
+ @FormatVND(_report.TotalOrders > 0 ? _report.TotalRevenue / _report.TotalOrders : 0) + Gia tri TB / don +
+
+ @* Discount Total *@ +
+
+ +
+
+ @FormatVND(_report.DiscountTotal) + Giam gia +
+
+ @* Completed Orders *@ +
+
+ +
+
+ @_report.CompletedOrders + Hoan thanh +
+
+ @* Cancelled Orders *@ +
+
+ +
+
+ @_report.CancelledOrders + Da huy +
+
+
+ + @* ── REVENUE BY PAYMENT METHOD ── *@ +
+ @* Payment Breakdown Chart *@ +
+
+

Phan tich thanh toan

+
+
+ @if (_report.PaymentBreakdown.Any()) + { +
+
+ +
+
+ @foreach (var p in _report.PaymentBreakdown) + { +
+
+ @GetPaymentMethodLabel(p.Method) + (@p.Count don) +
+ @FormatVND(p.Amount) +
+ } +
+
+ } + else + { +
Khong co du lieu thanh toan.
+ } +
+
+ + @* Revenue Breakdown Cards *@ +
+
+

Doanh thu theo loai

+
+
+
+
+ + Tien mat +
+ @FormatVND(_report.CashRevenue) +
+
+
+ + The +
+ @FormatVND(_report.CardRevenue) +
+
+
+ + Truc tuyen +
+ @FormatVND(_report.OnlineRevenue) +
+
+
+
+ + @* ── HOURLY REVENUE CHART ── *@ +
+
+

Doanh thu theo gio

+
+
+ @if (_hourlyChartData.Length > 0) + { + + } + else + { +
Khong co du lieu doanh thu theo gio.
+ } +
+
+ + @* ── TOP 10 ITEMS TABLE ── *@ +
+
+

Top 10 san pham ban chay

+
+
+ @if (_report.TopItems.Any()) + { + + + # + Ten san pham + So luong + Doanh thu + + + @((_report.TopItems.IndexOf(context) + 1)) + @context.ItemName + @context.Quantity + @FormatVND(context.Revenue) + + + } + else + { +
Khong co du lieu san pham.
+ } +
+
+ } +
+ +@code { + // EN: Selected date for the report / VI: Ngay duoc chon cho bao cao + private DateTime? _selectedDate = DateTime.Today; + private bool _loading = false; + private PosDataService.EodReportInfo? _report; + + // EN: Chart data arrays / VI: Mang du lieu bieu do + private double[] _paymentChartData = Array.Empty(); + private string[] _paymentChartLabels = Array.Empty(); + private double[] _hourlyChartData = Array.Empty(); + private string[] _hourlyChartLabels = Array.Empty(); + private List _hourlyChartSeries = new(); + + private ChartOptions _donutOptions = new() + { + ChartPalette = new[] { "#22C55E", "#3B82F6", "#8B5CF6", "#F59E0B", "#EC4899", "#FF5C00" } + }; + + private ChartOptions _barOptions = new() + { + ChartPalette = new[] { "#FF5C00" }, + YAxisTicks = 5 + }; + + /// + /// EN: Get the current shop ID from the admin context. + /// VI: Lay shop ID hien tai tu admin context. + /// + private Guid? GetCurrentShopId() + { + // EN: Try to get shop ID from query string or AuthStateService context + // VI: Thu lay shop ID tu query string hoac AuthStateService context + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + if (Guid.TryParse(query["shopId"], out var qsShopId)) + return qsShopId; + return null; + } + + /// + /// EN: Load the EOD report for the selected date. + /// VI: Tai bao cao cuoi ngay cho ngay duoc chon. + /// + private async Task LoadReportAsync() + { + var shopId = GetCurrentShopId(); + if (!shopId.HasValue) + { + Snackbar.Add("Vui long chon shop truoc khi xem bao cao.", Severity.Warning); + return; + } + + if (!_selectedDate.HasValue) + { + Snackbar.Add("Vui long chon ngay bao cao.", Severity.Warning); + return; + } + + _loading = true; + _report = null; + StateHasChanged(); + + try + { + _report = await DataService.GetEodReportAsync(shopId.Value, _selectedDate.Value); + if (_report != null) + { + BuildChartData(); + Snackbar.Add($"Da tai bao cao ngay {_selectedDate.Value:dd/MM/yyyy}.", Severity.Success); + } + else + { + Snackbar.Add("Khong the tai bao cao. Vui long thu lai.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Loi: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + /// + /// EN: Close the business day with confirmation dialog. + /// VI: Dong ngay kinh doanh voi dialog xac nhan. + /// + private async Task CloseDayAsync() + { + var shopId = GetCurrentShopId(); + if (!shopId.HasValue || !_selectedDate.HasValue) return; + + var confirmed = await DialogService.ShowMessageBox( + "Xac nhan dong ngay", + $"Ban co chac chan muon dong ngay {_selectedDate.Value:dd/MM/yyyy}? Hanh dong nay se tao bao cao cuoi ngay chinh thuc.", + yesText: "Dong ngay", + cancelText: "Huy"); + + if (confirmed != true) return; + + _loading = true; + StateHasChanged(); + + try + { + var result = await DataService.CloseDayAsync(shopId.Value, _selectedDate.Value); + if (result != null && result.Success) + { + if (result.Report != null) + { + _report = result.Report; + BuildChartData(); + } + + var message = $"Da dong ngay {_selectedDate.Value:dd/MM/yyyy} thanh cong."; + if (result.PendingOrderCount > 0) + { + message += $" Luu y: {result.PendingOrderCount} don hang van dang cho xu ly."; + Snackbar.Add(message, Severity.Warning); + } + else + { + Snackbar.Add(message, Severity.Success); + } + } + else + { + Snackbar.Add(result?.Message ?? "Khong the dong ngay. Vui long thu lai.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Loi: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + /// + /// EN: Build chart data arrays from the report. + /// VI: Xay dung mang du lieu bieu do tu bao cao. + /// + private void BuildChartData() + { + if (_report == null) return; + + // EN: Payment breakdown donut chart / VI: Bieu do tron phan tich thanh toan + if (_report.PaymentBreakdown.Any()) + { + _paymentChartData = _report.PaymentBreakdown.Select(p => (double)p.Amount).ToArray(); + _paymentChartLabels = _report.PaymentBreakdown.Select(p => GetPaymentMethodLabel(p.Method)).ToArray(); + } + else + { + _paymentChartData = Array.Empty(); + _paymentChartLabels = Array.Empty(); + } + + // EN: Hourly revenue bar chart / VI: Bieu do cot doanh thu theo gio + if (_report.HourlyRevenue.Any()) + { + _hourlyChartData = _report.HourlyRevenue.Select(h => (double)h.Revenue).ToArray(); + _hourlyChartLabels = _report.HourlyRevenue.Select(h => $"{h.Hour:00}:00").ToArray(); + _hourlyChartSeries = new List + { + new ChartSeries { Name = "Doanh thu", Data = _hourlyChartData } + }; + } + else + { + _hourlyChartData = Array.Empty(); + _hourlyChartLabels = Array.Empty(); + _hourlyChartSeries = new(); + } + } + + /// + /// EN: Get Vietnamese label for payment method. + /// VI: Lay nhan tieng Viet cho phuong thuc thanh toan. + /// + private static string GetPaymentMethodLabel(string method) => method.ToLowerInvariant() switch + { + "cash" => "Tien mat", + "card" => "The", + "vnpay" => "VNPay", + "momo" => "MoMo", + "qr" => "QR Code", + "transfer" => "Chuyen khoan", + "unknown" => "Chua xac dinh", + _ => method + }; + + private static string FormatVND(decimal val) => val.ToString("N0") + " d"; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index 33e88893..caff00c8 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -1358,6 +1358,47 @@ public class PosDataService return resp.IsSuccessStatusCode; } + // ═══ EOD REPORTS / DAILY CLOSE ═══ + + // EN: EOD report DTO — matches backend EodReportDto + // VI: DTO bao cao cuoi ngay — khop voi EodReportDto backend + public record EodReportInfo( + DateTime ReportDate, Guid ShopId, + int TotalOrders, int CompletedOrders, int CancelledOrders, + decimal TotalRevenue, decimal CashRevenue, decimal CardRevenue, decimal OnlineRevenue, + decimal DiscountTotal, + List PaymentBreakdown, + List TopItems, + List HourlyRevenue); + + public record EodPaymentBreakdownInfo(string Method, int Count, decimal Amount); + public record EodTopItemInfo(string ItemName, int Quantity, decimal Revenue); + public record EodHourlyRevenueInfo(int Hour, int OrderCount, decimal Revenue); + + // EN: Close day result DTO + // VI: DTO ket qua dong ngay + public record CloseDayResultInfo(bool Success, EodReportInfo? Report, string? Message, int PendingOrderCount); + + /// + /// EN: Get End-of-Day report for a shop on a specific date. + /// VI: Lay bao cao cuoi ngay cho shop vao ngay cu the. + /// + public async Task GetEodReportAsync(Guid shopId, DateTime date) + { + var url = $"api/bff/reports/eod?shopId={shopId}&date={date:yyyy-MM-dd}"; + return await GetObjectFromApiAsync(url); + } + + /// + /// EN: Close the business day and generate final EOD report. + /// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung. + /// + public async Task CloseDayAsync(Guid shopId, DateTime date) + { + var url = "api/bff/reports/close-day"; + return await PostAndGetAsync(url, new { shopId, closeDate = date.ToString("yyyy-MM-dd") }); + } + // ═══ SERVICE HEALTH CHECK ═══ public record ServiceHealthInfo(string Name, string Icon, bool IsOnline, int? LatencyMs); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs index 20b221fb..b0c6d520 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs @@ -45,4 +45,40 @@ public class ReportsController : ControllerBase if (shopId.HasValue) qs.Add($"shopId={shopId}"); return _order.GetAsync($"/api/v1/reports/top-products?{string.Join("&", qs)}").ProxyAsync(); } + + /// + /// EN: Get End-of-Day report for a shop. + /// VI: Lay bao cao cuoi ngay cho shop. + /// + [HttpGet("reports/eod")] + public Task GetEodReport( + [FromQuery] Guid? shopId = null, + [FromQuery] string? date = null) + { + var qs = new List(); + if (shopId.HasValue) qs.Add($"shopId={shopId}"); + if (!string.IsNullOrEmpty(date)) qs.Add($"date={Uri.EscapeDataString(date)}"); + var query = qs.Any() ? "?" + string.Join("&", qs) : ""; + return _order.GetAsync($"/api/v1/reports/eod{query}").ProxyAsync(); + } + + /// + /// EN: Close the business day and generate final EOD report. + /// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay. + /// + [HttpPost("reports/close-day")] + public Task CloseDay([FromBody] CloseDayBffRequest request) + { + return _order.PostAsJsonAsync("/api/v1/reports/close-day", new + { + shopId = request.ShopId, + closeDate = request.CloseDate + }).ProxyAsync(); + } } + +/// +/// EN: BFF request for closing the business day. +/// VI: BFF request de dong ngay kinh doanh. +/// +public record CloseDayBffRequest(Guid ShopId, string? CloseDate = null); diff --git a/infra/traefik/dynamic/middlewares.yml b/infra/traefik/dynamic/middlewares.yml index 88cd5183..022474ad 100644 --- a/infra/traefik/dynamic/middlewares.yml +++ b/infra/traefik/dynamic/middlewares.yml @@ -27,10 +27,33 @@ http: accessControlAllowCredentials: true accessControlMaxAge: 86400 + # EN: STRICT rate limit for auth endpoints (login, register, 2FA) - 10 req/min per IP + # VI: Rate limit NGHIEM NGAT cho auth endpoints (login, register, 2FA) - 10 req/phut moi IP auth-ratelimit: + rateLimit: + average: 10 + burst: 5 + + # EN: STRICT rate limit for payment endpoints - 30 req/min per IP + # VI: Rate limit NGHIEM NGAT cho payment endpoints - 30 req/phut moi IP + payment-ratelimit: + rateLimit: + average: 30 + burst: 10 + + # EN: MODERATE rate limit for general API - 100 req/min per IP + # VI: Rate limit VUA PHAI cho API chung - 100 req/phut moi IP + api-ratelimit: rateLimit: average: 100 burst: 50 + # EN: RELAXED rate limit for SignalR hubs (real-time needs higher limits) - 500 req/min + # VI: Rate limit THOAI MAI cho SignalR hubs (real-time can gioi han cao hon) - 500 req/phut + hub-ratelimit: + rateLimit: + average: 500 + burst: 100 + compress: compress: {} diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml index d169fc53..71484602 100644 --- a/infra/traefik/dynamic/routes.yml +++ b/infra/traefik/dynamic/routes.yml @@ -16,7 +16,7 @@ http: service: iam-service priority: 100 middlewares: - - auth-ratelimit + - api-ratelimit - cors - secure-headers entryPoints: @@ -29,7 +29,7 @@ http: service: iam-service priority: 150 middlewares: - - auth-ratelimit + - api-ratelimit - cors - secure-headers entryPoints: @@ -60,6 +60,7 @@ http: service: storage-service priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: @@ -72,6 +73,7 @@ http: service: membership-service priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: @@ -84,6 +86,7 @@ http: service: merchant-service priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: @@ -96,6 +99,7 @@ http: service: order-service priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: @@ -108,6 +112,7 @@ http: service: order-service priority: 110 middlewares: + - hub-ratelimit - cors entryPoints: - web @@ -119,6 +124,7 @@ http: service: fnb-engine priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: @@ -131,6 +137,7 @@ http: service: inventory-service priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: @@ -143,6 +150,7 @@ http: service: wallet-service priority: 100 middlewares: + - payment-ratelimit - cors - secure-headers entryPoints: @@ -155,6 +163,7 @@ http: service: catalog-service priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: @@ -167,6 +176,7 @@ http: service: booking-service priority: 100 middlewares: + - api-ratelimit - cors - secure-headers entryPoints: diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Validations/CatalogCommandValidators.cs b/services/catalog-service-net/src/CatalogService.API/Application/Validations/CatalogCommandValidators.cs new file mode 100644 index 00000000..d1c8d6d2 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Validations/CatalogCommandValidators.cs @@ -0,0 +1,184 @@ +// EN: Validators for Catalog commands (products, categories). +// VI: Validators cho cac commands Catalog (san pham, danh muc). + +using FluentValidation; +using CatalogService.API.Application.Commands; + +namespace CatalogService.API.Application.Validations; + +/// +/// EN: Validator for CreateProductCommand. +/// VI: Validator cho CreateProductCommand. +/// +public class CreateProductCommandValidator : AbstractValidator +{ + private static readonly string[] ValidProductTypes = ["Physical", "Service", "PreparedFood"]; + + public CreateProductCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Product name is required / Ten san pham la bat buoc") + .MaximumLength(200) + .WithMessage("Product name must not exceed 200 characters / Ten san pham khong vuot qua 200 ky tu"); + + RuleFor(x => x.Description) + .MaximumLength(2000) + .WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu") + .When(x => x.Description != null); + + RuleFor(x => x.Price) + .GreaterThanOrEqualTo(0) + .WithMessage("Price must not be negative / Gia khong duoc am"); + + RuleFor(x => x.Type) + .NotEmpty() + .WithMessage("Product type is required / Loai san pham la bat buoc") + .Must(t => ValidProductTypes.Contains(t)) + .WithMessage("Invalid product type. Valid values: Physical, Service, PreparedFood / Loai san pham khong hop le"); + + RuleFor(x => x.Sku) + .MaximumLength(100) + .WithMessage("SKU must not exceed 100 characters / Ma SKU khong vuot qua 100 ky tu") + .When(x => x.Sku != null); + + RuleFor(x => x.ImageUrl) + .MaximumLength(2048) + .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu") + .When(x => x.ImageUrl != null); + } +} + +/// +/// EN: Validator for UpdateProductCommand. +/// VI: Validator cho UpdateProductCommand. +/// +public class UpdateProductCommandValidator : AbstractValidator +{ + public UpdateProductCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID la bat buoc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Product name is required / Ten san pham la bat buoc") + .MaximumLength(200) + .WithMessage("Product name must not exceed 200 characters / Ten san pham khong vuot qua 200 ky tu"); + + RuleFor(x => x.Description) + .MaximumLength(2000) + .WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu") + .When(x => x.Description != null); + + RuleFor(x => x.Price) + .GreaterThanOrEqualTo(0) + .WithMessage("Price must not be negative / Gia khong duoc am"); + + RuleFor(x => x.ImageUrl) + .MaximumLength(2048) + .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu") + .When(x => x.ImageUrl != null); + } +} + +/// +/// EN: Validator for DeleteProductCommand. +/// VI: Validator cho DeleteProductCommand. +/// +public class DeleteProductCommandValidator : AbstractValidator +{ + public DeleteProductCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID la bat buoc"); + } +} + +/// +/// EN: Validator for CreateCategoryCommand. +/// VI: Validator cho CreateCategoryCommand. +/// +public class CreateCategoryCommandValidator : AbstractValidator +{ + public CreateCategoryCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Category name is required / Ten danh muc la bat buoc") + .MaximumLength(200) + .WithMessage("Category name must not exceed 200 characters / Ten danh muc khong vuot qua 200 ky tu"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must not exceed 1000 characters / Mo ta khong vuot qua 1000 ky tu") + .When(x => x.Description != null); + + RuleFor(x => x.DisplayOrder) + .GreaterThanOrEqualTo(0) + .WithMessage("Display order must not be negative / Thu tu hien thi khong duoc am"); + + RuleFor(x => x.ImageUrl) + .MaximumLength(2048) + .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu") + .When(x => x.ImageUrl != null); + } +} + +/// +/// EN: Validator for UpdateCategoryCommand. +/// VI: Validator cho UpdateCategoryCommand. +/// +public class UpdateCategoryCommandValidator : AbstractValidator +{ + public UpdateCategoryCommandValidator() + { + RuleFor(x => x.CategoryId) + .NotEmpty() + .WithMessage("Category ID is required / Category ID la bat buoc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Category name is required / Ten danh muc la bat buoc") + .MaximumLength(200) + .WithMessage("Category name must not exceed 200 characters / Ten danh muc khong vuot qua 200 ky tu"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must not exceed 1000 characters / Mo ta khong vuot qua 1000 ky tu") + .When(x => x.Description != null); + + RuleFor(x => x.DisplayOrder) + .GreaterThanOrEqualTo(0) + .WithMessage("Display order must not be negative / Thu tu hien thi khong duoc am"); + + RuleFor(x => x.ImageUrl) + .MaximumLength(2048) + .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu") + .When(x => x.ImageUrl != null); + } +} + +/// +/// EN: Validator for DeleteCategoryCommand. +/// VI: Validator cho DeleteCategoryCommand. +/// +public class DeleteCategoryCommandValidator : AbstractValidator +{ + public DeleteCategoryCommandValidator() + { + RuleFor(x => x.CategoryId) + .NotEmpty() + .WithMessage("Category ID is required / Category ID la bat buoc"); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/FnbCommandValidators.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/FnbCommandValidators.cs new file mode 100644 index 00000000..da148ad5 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/FnbCommandValidators.cs @@ -0,0 +1,201 @@ +// EN: Validators for FnB Engine commands (sessions, tables, tickets, reservations). +// VI: Validators cho cac commands FnB Engine (phien, ban, phieu bep, dat cho). + +using FluentValidation; +using FnbEngine.API.Application.Commands; + +namespace FnbEngine.API.Application.Validations; + +/// +/// EN: Validator for OpenSessionCommand. +/// VI: Validator cho OpenSessionCommand. +/// +public class OpenSessionCommandValidator : AbstractValidator +{ + public OpenSessionCommandValidator() + { + RuleFor(x => x.TableId) + .NotEmpty() + .WithMessage("Table ID is required / Table ID la bat buoc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + + RuleFor(x => x.GuestCount) + .GreaterThan(0) + .WithMessage("Guest count must be greater than zero / So khach phai lon hon 0") + .LessThanOrEqualTo(100) + .WithMessage("Guest count must not exceed 100 / So khach khong vuot qua 100"); + } +} + +/// +/// EN: Validator for CloseSessionCommand. +/// VI: Validator cho CloseSessionCommand. +/// +public class CloseSessionCommandValidator : AbstractValidator +{ + public CloseSessionCommandValidator() + { + RuleFor(x => x.SessionId) + .NotEmpty() + .WithMessage("Session ID is required / Session ID la bat buoc"); + } +} + +/// +/// EN: Validator for ChangeTableStatusCommand. +/// VI: Validator cho ChangeTableStatusCommand. +/// +public class ChangeTableStatusCommandValidator : AbstractValidator +{ + private static readonly string[] ValidStatuses = ["Available", "Occupied", "Cleaning", "Reserved", "OutOfService"]; + + public ChangeTableStatusCommandValidator() + { + RuleFor(x => x.TableId) + .NotEmpty() + .WithMessage("Table ID is required / Table ID la bat buoc"); + + RuleFor(x => x.Status) + .NotEmpty() + .WithMessage("Status is required / Trang thai la bat buoc") + .MaximumLength(50) + .WithMessage("Status must not exceed 50 characters / Trang thai khong vuot qua 50 ky tu") + .Must(s => ValidStatuses.Contains(s)) + .WithMessage("Invalid status. Valid values: Available, Occupied, Cleaning, Reserved, OutOfService / Trang thai khong hop le"); + } +} + +/// +/// EN: Validator for UpdateTableCommand. +/// VI: Validator cho UpdateTableCommand. +/// +public class UpdateTableCommandValidator : AbstractValidator +{ + public UpdateTableCommandValidator() + { + RuleFor(x => x.TableId) + .NotEmpty() + .WithMessage("Table ID is required / Table ID la bat buoc"); + + RuleFor(x => x.Capacity) + .GreaterThan(0) + .WithMessage("Capacity must be greater than zero / Suc chua phai lon hon 0") + .When(x => x.Capacity.HasValue); + + RuleFor(x => x.Zone) + .MaximumLength(100) + .WithMessage("Zone must not exceed 100 characters / Khu vuc khong vuot qua 100 ky tu") + .When(x => x.Zone != null); + + RuleFor(x => x.HourlyRate) + .GreaterThanOrEqualTo(0) + .WithMessage("Hourly rate must not be negative / Gia theo gio khong duoc am") + .When(x => x.HourlyRate.HasValue); + } +} + +/// +/// EN: Validator for UpdateTicketStatusCommand. +/// VI: Validator cho UpdateTicketStatusCommand. +/// +public class UpdateTicketStatusCommandValidator : AbstractValidator +{ + private static readonly string[] ValidStatuses = ["Pending", "InProgress", "Ready", "Served", "Cancelled"]; + + public UpdateTicketStatusCommandValidator() + { + RuleFor(x => x.TicketId) + .NotEmpty() + .WithMessage("Ticket ID is required / Ticket ID la bat buoc"); + + RuleFor(x => x.Status) + .NotEmpty() + .WithMessage("Status is required / Trang thai la bat buoc") + .MaximumLength(50) + .WithMessage("Status must not exceed 50 characters / Trang thai khong vuot qua 50 ky tu") + .Must(s => ValidStatuses.Contains(s)) + .WithMessage("Invalid status. Valid values: Pending, InProgress, Ready, Served, Cancelled / Trang thai khong hop le"); + } +} + +/// +/// EN: Validator for CreateReservationCommand. +/// VI: Validator cho CreateReservationCommand. +/// +public class CreateReservationCommandValidator : AbstractValidator +{ + public CreateReservationCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + + RuleFor(x => x.GuestName) + .NotEmpty() + .WithMessage("Guest name is required / Ten khach la bat buoc") + .MaximumLength(200) + .WithMessage("Guest name must not exceed 200 characters / Ten khach khong vuot qua 200 ky tu"); + + RuleFor(x => x.PartySize) + .GreaterThan(0) + .WithMessage("Party size must be greater than zero / So nguoi phai lon hon 0") + .LessThanOrEqualTo(100) + .WithMessage("Party size must not exceed 100 / So nguoi khong vuot qua 100"); + + RuleFor(x => x.ReservationTime) + .GreaterThan(DateTime.UtcNow.AddMinutes(-5)) + .WithMessage("Reservation time must be in the future / Thoi gian dat cho phai trong tuong lai"); + + RuleFor(x => x.Phone) + .MaximumLength(20) + .WithMessage("Phone must not exceed 20 characters / So dien thoai khong vuot qua 20 ky tu") + .Matches(@"^[\d\+\-\(\)\s]+$") + .WithMessage("Invalid phone format / Dinh dang so dien thoai khong hop le") + .When(x => x.Phone != null); + + RuleFor(x => x.Note) + .MaximumLength(1000) + .WithMessage("Note must not exceed 1000 characters / Ghi chu khong vuot qua 1000 ky tu") + .When(x => x.Note != null); + } +} + +/// +/// EN: Validator for CreateKitchenTicketCommand. +/// VI: Validator cho CreateKitchenTicketCommand. +/// +public class CreateKitchenTicketCommandValidator : AbstractValidator +{ + public CreateKitchenTicketCommandValidator() + { + RuleFor(x => x.SessionId) + .NotEmpty() + .WithMessage("Session ID is required / Session ID la bat buoc"); + + RuleFor(x => x.OrderItemId) + .NotEmpty() + .WithMessage("Order item ID is required / Order item ID la bat buoc"); + + RuleFor(x => x.ItemName) + .NotEmpty() + .WithMessage("Item name is required / Ten mon la bat buoc") + .MaximumLength(200) + .WithMessage("Item name must not exceed 200 characters / Ten mon khong vuot qua 200 ky tu"); + + RuleFor(x => x.Station) + .MaximumLength(100) + .WithMessage("Station must not exceed 100 characters / Tram bep khong vuot qua 100 ky tu") + .When(x => x.Station != 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.Quantity) + .GreaterThan(0) + .WithMessage("Quantity must be greater than zero / So luong phai lon hon 0"); + } +} diff --git a/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/IntegrationEvents/KitchenTicketServedDomainEventHandlerTests.cs b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/IntegrationEvents/KitchenTicketServedDomainEventHandlerTests.cs new file mode 100644 index 00000000..b1cc1e00 --- /dev/null +++ b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/IntegrationEvents/KitchenTicketServedDomainEventHandlerTests.cs @@ -0,0 +1,324 @@ +// EN: Unit tests for KitchenTicketServedDomainEventHandler - inventory deduction on served tickets. +// VI: Unit tests cho KitchenTicketServedDomainEventHandler - tru kho khi phieu da phuc vu. + +using FluentAssertions; +using FnbEngine.API.Application.IntegrationEvents.EventHandlers; +using FnbEngine.Domain.AggregatesModel.KitchenAggregate; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; +using FnbEngine.Domain.AggregatesModel.SessionAggregate; +using FnbEngine.Domain.Events; +using FnbEngine.Infrastructure.ExternalServices; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace FnbEngine.UnitTests.Application.IntegrationEvents; + +/// +/// EN: Unit tests for KitchenTicketServedDomainEventHandler. +/// VI: Unit tests cho KitchenTicketServedDomainEventHandler. +/// +public class KitchenTicketServedDomainEventHandlerTests +{ + private readonly Mock _recipeRepositoryMock; + private readonly Mock _sessionRepositoryMock; + private readonly Mock _inventoryClientMock; + private readonly Mock> _loggerMock; + private readonly KitchenTicketServedDomainEventHandler _handler; + + private readonly Guid _shopId = Guid.NewGuid(); + private readonly Guid _sessionId = Guid.NewGuid(); + private readonly Guid _productId = Guid.NewGuid(); + + public KitchenTicketServedDomainEventHandlerTests() + { + _recipeRepositoryMock = new Mock(); + _sessionRepositoryMock = new Mock(); + _inventoryClientMock = new Mock(); + _loggerMock = new Mock>(); + + _handler = new KitchenTicketServedDomainEventHandler( + _recipeRepositoryMock.Object, + _sessionRepositoryMock.Object, + _inventoryClientMock.Object, + _loggerMock.Object); + } + + /// + /// EN: Helper to create a KitchenTicket for testing. + /// VI: Helper de tao KitchenTicket de test. + /// + private KitchenTicket CreateTicket(Guid? productId = null, int quantity = 1) + { + return new KitchenTicket( + _sessionId, + Guid.NewGuid(), + productId ?? _productId, + "Pho Bo", + quantity, + "Kitchen", + 0); + } + + /// + /// EN: Helper to create a Session for testing. + /// VI: Helper de tao Session de test. + /// + private Session CreateSession() + { + return new Session(Guid.NewGuid(), _shopId, 2); + } + + /// + /// EN: Helper to create a Recipe with linked ingredients. + /// VI: Helper de tao Recipe voi nguyen lieu co lien ket. + /// + private Recipe CreateRecipeWithLinkedIngredients(int ingredientCount = 2) + { + var recipe = new Recipe(_shopId, _productId, "Pho Bo Recipe", "Boil broth", 30); + for (int i = 0; i < ingredientCount; i++) + { + recipe.AddIngredient( + $"Ingredient {i + 1}", + quantity: 100, + unit: "g", + costPerUnit: 10_000m, + inventoryItemId: Guid.NewGuid(), + quantityPerServing: 50m); + } + return recipe; + } + + /// + /// EN: Helper to create a Recipe with no linked inventory items. + /// VI: Helper de tao Recipe khong co lien ket den inventory items. + /// + private Recipe CreateRecipeWithoutLinkedIngredients() + { + var recipe = new Recipe(_shopId, _productId, "Simple Drink", "Pour and serve", 2); + recipe.AddIngredient("Water", 200, "ml", 0, inventoryItemId: null, quantityPerServing: 0); + return recipe; + } + + [Fact] + public async Task Handle_WithValidEvent_ShouldCallInventoryClient() + { + // Arrange + var ticket = CreateTicket(); + var session = CreateSession(); + var recipe = CreateRecipeWithLinkedIngredients(); + var notification = new KitchenTicketServedDomainEvent(ticket); + + _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny())) + .ReturnsAsync(session); + _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny())) + .ReturnsAsync(recipe); + _inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + await _handler.Handle(notification, CancellationToken.None); + + // Assert + // EN: Should call inventory client exactly once with the correct deduction request. + // VI: Nen goi inventory client dung 1 lan voi request tru kho chinh xac. + _inventoryClientMock.Verify( + c => c.DeductInventoryAsync( + It.Is(req => + req.ShopId == _shopId && + req.ReferenceId == ticket.Id && + req.ReferenceType == "KitchenTicket" && + req.Items.Count == 2), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Handle_WithNoRecipeFound_ShouldSkipDeduction() + { + // Arrange + var ticket = CreateTicket(); + var session = CreateSession(); + var notification = new KitchenTicketServedDomainEvent(ticket); + + _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny())) + .ReturnsAsync(session); + _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny())) + .ReturnsAsync((Recipe?)null); + + // Act + await _handler.Handle(notification, CancellationToken.None); + + // Assert + // EN: No recipe means no inventory deduction should be attempted. + // VI: Khong co cong thuc nghia la khong nen co gang tru kho. + _inventoryClientMock.Verify( + c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Handle_WhenInventoryClientFails_ShouldLogErrorNotThrow() + { + // Arrange + var ticket = CreateTicket(); + var session = CreateSession(); + var recipe = CreateRecipeWithLinkedIngredients(); + var notification = new KitchenTicketServedDomainEvent(ticket); + + _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny())) + .ReturnsAsync(session); + _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny())) + .ReturnsAsync(recipe); + _inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new HttpRequestException("Inventory service unavailable")); + + // Act + var action = () => _handler.Handle(notification, CancellationToken.None); + + // Assert + // EN: Should NOT throw - inventory failure should not block kitchen workflow. + // VI: KHONG nen throw - loi tru kho khong nen chan luong bep. + await action.Should().NotThrowAsync(); + + _inventoryClientMock.Verify( + c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Handle_WithMultipleIngredients_ShouldDeductAll() + { + // Arrange + var ticket = CreateTicket(quantity: 3); + var session = CreateSession(); + var recipe = CreateRecipeWithLinkedIngredients(ingredientCount: 4); + var notification = new KitchenTicketServedDomainEvent(ticket); + + _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny())) + .ReturnsAsync(session); + _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny())) + .ReturnsAsync(recipe); + + DeductInventoryRequest? capturedRequest = null; + _inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny())) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(true); + + // Act + await _handler.Handle(notification, CancellationToken.None); + + // Assert + // EN: Should send all 4 ingredients in one deduction request. + // VI: Nen gui tat ca 4 nguyen lieu trong mot request tru kho. + capturedRequest.Should().NotBeNull(); + capturedRequest!.Items.Should().HaveCount(4); + + // EN: Each ingredient amount should be multiplied by ticket quantity (3). + // VI: So luong moi nguyen lieu nen duoc nhan voi so luong phieu (3). + capturedRequest.Items.Should().AllSatisfy(item => + { + // EN: quantityPerServing=50, ticket.Quantity=3, so Math.Ceiling(50*3)=150 + // VI: quantityPerServing=50, ticket.Quantity=3, nen Math.Ceiling(50*3)=150 + item.Amount.Should().Be(150); + }); + } + + [Fact] + public async Task Handle_WithNoLinkedInventoryItems_ShouldSkipDeduction() + { + // Arrange + var ticket = CreateTicket(); + var session = CreateSession(); + var recipe = CreateRecipeWithoutLinkedIngredients(); + var notification = new KitchenTicketServedDomainEvent(ticket); + + _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny())) + .ReturnsAsync(session); + _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny())) + .ReturnsAsync(recipe); + + // Act + await _handler.Handle(notification, CancellationToken.None); + + // Assert + // EN: Recipe with no linked inventory items should not trigger deduction. + // VI: Cong thuc khong co nguyen lieu lien ket kho khong nen kich hoat tru kho. + _inventoryClientMock.Verify( + c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Handle_WithSessionNotFound_ShouldSkipDeduction() + { + // Arrange + var ticket = CreateTicket(); + var notification = new KitchenTicketServedDomainEvent(ticket); + + _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny())) + .ReturnsAsync((Session?)null); + + // Act + await _handler.Handle(notification, CancellationToken.None); + + // Assert + // EN: Missing session means we cannot determine the shop, so skip deduction. + // VI: Thieu session nghia la khong the xac dinh shop, nen bo qua tru kho. + _recipeRepositoryMock.Verify( + r => r.GetByProductIdAndShopAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _inventoryClientMock.Verify( + c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Handle_WhenRepositoryThrows_ShouldNotThrow() + { + // Arrange + var ticket = CreateTicket(); + var notification = new KitchenTicketServedDomainEvent(ticket); + + _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Database connection failed")); + + // Act + var action = () => _handler.Handle(notification, CancellationToken.None); + + // Assert + // EN: Repository failures should be caught and logged, not propagated. + // VI: Loi repository nen duoc bat va log, khong nen lan truyen. + await action.Should().NotThrowAsync(); + } + + [Fact] + public void Constructor_WithNullRecipeRepository_ShouldThrowArgumentNullException() + { + // Act + var action = () => new KitchenTicketServedDomainEventHandler( + null!, + _sessionRepositoryMock.Object, + _inventoryClientMock.Object, + _loggerMock.Object); + + // Assert + action.Should().Throw() + .And.ParamName.Should().Be("recipeRepository"); + } + + [Fact] + public void Constructor_WithNullInventoryClient_ShouldThrowArgumentNullException() + { + // Act + var action = () => new KitchenTicketServedDomainEventHandler( + _recipeRepositoryMock.Object, + _sessionRepositoryMock.Object, + null!, + _loggerMock.Object); + + // Assert + action.Should().Throw() + .And.ParamName.Should().Be("inventoryClient"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/AuthCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/AuthCommandValidators.cs new file mode 100644 index 00000000..87984f63 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/AuthCommandValidators.cs @@ -0,0 +1,167 @@ +// EN: Validators for Auth commands (external login, 2FA, logout). +// VI: Validators cho cac commands Auth (external login, 2FA, logout). + +using FluentValidation; +using IamService.API.Application.Commands.Auth; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for ExternalLoginCommand. +/// VI: Validator cho ExternalLoginCommand. +/// +public class ExternalLoginCommandValidator : AbstractValidator +{ + private static readonly string[] ValidProviders = ["Google", "Facebook", "Apple", "google", "facebook", "apple"]; + + public ExternalLoginCommandValidator() + { + RuleFor(x => x.Provider) + .NotEmpty() + .WithMessage("Provider is required / Nha cung cap la bat buoc") + .MaximumLength(50) + .WithMessage("Provider must not exceed 50 characters / Nha cung cap khong vuot qua 50 ky tu") + .Must(p => ValidProviders.Contains(p)) + .WithMessage("Invalid provider. Valid values: Google, Facebook, Apple / Nha cung cap khong hop le"); + + RuleFor(x => x.ProviderUserId) + .NotEmpty() + .WithMessage("Provider user ID is required / ID nguoi dung tu nha cung cap la bat buoc") + .MaximumLength(256) + .WithMessage("Provider user ID must not exceed 256 characters / ID nguoi dung khong vuot qua 256 ky tu"); + + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required / Email la bat buoc") + .EmailAddress() + .WithMessage("Invalid email format / Dinh dang email khong hop le") + .MaximumLength(256) + .WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu"); + + RuleFor(x => x.Name) + .MaximumLength(200) + .WithMessage("Name must not exceed 200 characters / Ten khong vuot qua 200 ky tu") + .When(x => x.Name != null); + + RuleFor(x => x.PictureUrl) + .MaximumLength(2048) + .WithMessage("Picture URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu") + .Must(url => url == null || Uri.TryCreate(url, UriKind.Absolute, out _)) + .WithMessage("Picture URL must be a valid URL / URL hinh anh phai hop le") + .When(x => x.PictureUrl != null); + } +} + +/// +/// EN: Validator for Enable2FACommand. +/// VI: Validator cho Enable2FACommand. +/// +public class Enable2FACommandValidator : AbstractValidator +{ + public Enable2FACommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + } +} + +/// +/// EN: Validator for Verify2FACommand. +/// VI: Validator cho Verify2FACommand. +/// +public class Verify2FACommandValidator : AbstractValidator +{ + public Verify2FACommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.Code) + .NotEmpty() + .WithMessage("2FA code is required / Ma 2FA la bat buoc") + .MaximumLength(10) + .WithMessage("2FA code must not exceed 10 characters / Ma 2FA khong vuot qua 10 ky tu") + .Matches(@"^\d{6}$") + .WithMessage("2FA code must be 6 digits / Ma 2FA phai la 6 chu so"); + } +} + +/// +/// EN: Validator for Disable2FACommand. +/// VI: Validator cho Disable2FACommand. +/// +public class Disable2FACommandValidator : AbstractValidator +{ + public Disable2FACommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.Code) + .NotEmpty() + .WithMessage("2FA code is required / Ma 2FA la bat buoc") + .MaximumLength(10) + .WithMessage("2FA code must not exceed 10 characters / Ma 2FA khong vuot qua 10 ky tu") + .Matches(@"^\d{6}$") + .WithMessage("2FA code must be 6 digits / Ma 2FA phai la 6 chu so"); + } +} + +/// +/// EN: Validator for LogoutCommand. +/// VI: Validator cho LogoutCommand. +/// +public class LogoutCommandValidator : AbstractValidator +{ + public LogoutCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + } +} + +/// +/// EN: Validator for SendVerificationEmailCommand. +/// VI: Validator cho SendVerificationEmailCommand. +/// +public class SendVerificationEmailCommandValidator : AbstractValidator +{ + public SendVerificationEmailCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required / Email la bat buoc") + .EmailAddress() + .WithMessage("Invalid email format / Dinh dang email khong hop le") + .MaximumLength(256) + .WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu"); + } +} + +/// +/// EN: Validator for ConfirmEmailCommand. +/// VI: Validator cho ConfirmEmailCommand. +/// +public class ConfirmEmailCommandValidator : AbstractValidator +{ + public ConfirmEmailCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required / Email la bat buoc") + .EmailAddress() + .WithMessage("Invalid email format / Dinh dang email khong hop le") + .MaximumLength(256) + .WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu"); + + RuleFor(x => x.Token) + .NotEmpty() + .WithMessage("Token is required / Token la bat buoc") + .MaximumLength(1024) + .WithMessage("Token must not exceed 1024 characters / Token khong vuot qua 1024 ky tu"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/RoleCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/RoleCommandValidators.cs new file mode 100644 index 00000000..462d43fa --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/RoleCommandValidators.cs @@ -0,0 +1,111 @@ +// EN: Validators for Role commands (create, update, delete, assign, remove). +// VI: Validators cho cac commands Role (tao, cap nhat, xoa, gan, go). + +using FluentValidation; +using IamService.API.Application.Commands.Roles; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for CreateRoleCommand. +/// VI: Validator cho CreateRoleCommand. +/// +public class CreateRoleCommandValidator : AbstractValidator +{ + public CreateRoleCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Role name is required / Ten role la bat buoc") + .MaximumLength(100) + .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu") + .Matches(@"^[a-zA-Z0-9_\-\.]+$") + .WithMessage("Role name must contain only letters, numbers, underscores, hyphens, and dots / Ten role chi chua chu cai, so, gach duoi, gach ngang va dau cham"); + + RuleFor(x => x.Description) + .MaximumLength(500) + .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu") + .When(x => x.Description != null); + } +} + +/// +/// EN: Validator for UpdateRoleCommand. +/// VI: Validator cho UpdateRoleCommand. +/// +public class UpdateRoleCommandValidator : AbstractValidator +{ + public UpdateRoleCommandValidator() + { + RuleFor(x => x.RoleId) + .NotEmpty() + .WithMessage("Role ID is required / Role ID la bat buoc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Role name is required / Ten role la bat buoc") + .MaximumLength(100) + .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu") + .Matches(@"^[a-zA-Z0-9_\-\.]+$") + .WithMessage("Role name must contain only letters, numbers, underscores, hyphens, and dots / Ten role chi chua chu cai, so, gach duoi, gach ngang va dau cham"); + + RuleFor(x => x.Description) + .MaximumLength(500) + .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu") + .When(x => x.Description != null); + } +} + +/// +/// EN: Validator for DeleteRoleCommand. +/// VI: Validator cho DeleteRoleCommand. +/// +public class DeleteRoleCommandValidator : AbstractValidator +{ + public DeleteRoleCommandValidator() + { + RuleFor(x => x.RoleId) + .NotEmpty() + .WithMessage("Role ID is required / Role ID la bat buoc"); + } +} + +/// +/// EN: Validator for AssignRoleToUserCommand. +/// VI: Validator cho AssignRoleToUserCommand. +/// +public class AssignRoleToUserCommandValidator : AbstractValidator +{ + public AssignRoleToUserCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.RoleName) + .NotEmpty() + .WithMessage("Role name is required / Ten role la bat buoc") + .MaximumLength(100) + .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu"); + } +} + +/// +/// EN: Validator for RemoveRoleFromUserCommand. +/// VI: Validator cho RemoveRoleFromUserCommand. +/// +public class RemoveRoleFromUserCommandValidator : AbstractValidator +{ + public RemoveRoleFromUserCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.RoleName) + .NotEmpty() + .WithMessage("Role name is required / Ten role la bat buoc") + .MaximumLength(100) + .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/UserCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/UserCommandValidators.cs new file mode 100644 index 00000000..039a20d8 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/UserCommandValidators.cs @@ -0,0 +1,45 @@ +// EN: Validators for User commands (update, delete). +// VI: Validators cho cac commands User (cap nhat, xoa). + +using FluentValidation; +using IamService.API.Application.Commands.Users; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for UpdateUserCommand. +/// VI: Validator cho UpdateUserCommand. +/// +public class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.FirstName) + .MaximumLength(100) + .WithMessage("First name must not exceed 100 characters / Ho khong vuot qua 100 ky tu") + .When(x => x.FirstName != null); + + RuleFor(x => x.LastName) + .MaximumLength(100) + .WithMessage("Last name must not exceed 100 characters / Ten khong vuot qua 100 ky tu") + .When(x => x.LastName != null); + } +} + +/// +/// EN: Validator for DeleteUserCommand. +/// VI: Validator cho DeleteUserCommand. +/// +public class DeleteUserCommandValidator : AbstractValidator +{ + public DeleteUserCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + } +} diff --git a/services/inventory-service-net/tests/InventoryService.UnitTests/Application/Commands/DeductInventoryCommandHandlerTests.cs b/services/inventory-service-net/tests/InventoryService.UnitTests/Application/Commands/DeductInventoryCommandHandlerTests.cs new file mode 100644 index 00000000..ab6b5a6e --- /dev/null +++ b/services/inventory-service-net/tests/InventoryService.UnitTests/Application/Commands/DeductInventoryCommandHandlerTests.cs @@ -0,0 +1,426 @@ +// EN: Unit tests for DeductInventoryCommandHandler - bulk inventory deduction with idempotency. +// VI: Unit tests cho DeductInventoryCommandHandler - tru kho hang loat voi idempotency. + +using FluentAssertions; +using InventoryService.API.Application.Commands.DeductInventory; +using InventoryService.Domain.AggregatesModel.InventoryAggregate; +using InventoryService.Domain.SeedWork; +using InventoryService.Infrastructure.Idempotency; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace InventoryService.UnitTests.Application.Commands; + +/// +/// EN: Unit tests for DeductInventoryCommandHandler. +/// VI: Unit tests cho DeductInventoryCommandHandler. +/// +public class DeductInventoryCommandHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly Mock _requestManagerMock; + private readonly Mock> _loggerMock; + private readonly Mock _unitOfWorkMock; + private readonly DeductInventoryCommandHandler _handler; + + public DeductInventoryCommandHandlerTests() + { + _repositoryMock = new Mock(); + _requestManagerMock = new Mock(); + _loggerMock = new Mock>(); + _unitOfWorkMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + _repositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object); + + _requestManagerMock.Setup(r => r.ExistAsync(It.IsAny())) + .ReturnsAsync(false); + _requestManagerMock.Setup(r => r.CreateRequestForCommandAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + _handler = new DeductInventoryCommandHandler( + _repositoryMock.Object, + _requestManagerMock.Object, + _loggerMock.Object); + } + + /// + /// EN: Helper to create an InventoryItem with a specified available quantity. + /// VI: Helper de tao InventoryItem voi so luong kha dung chi dinh. + /// + private static InventoryItem CreateInventoryItemWithStock(Guid id, int quantity) + { + var item = new InventoryItem(Guid.NewGuid(), Guid.NewGuid(), reorderLevel: 5); + // EN: Use reflection to set Id and quantity since they use private fields. + // VI: Dung reflection de set Id va quantity vi chung dung private fields. + typeof(InventoryItem).BaseType!.GetProperty("Id")!.SetValue(item, id); + if (quantity > 0) + { + item.StockIn(quantity, "Initial stock for test"); + } + return item; + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldDeductInventory() + { + // Arrange + var itemId = Guid.NewGuid(); + var inventoryItem = CreateInventoryItemWithStock(itemId, 100); + + _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny())) + .ReturnsAsync(inventoryItem); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: Guid.NewGuid(), + ReferenceType: "KitchenTicket", + Reason: "Test deduction", + Items: new List + { + new(itemId, 10, "pcs", "Sugar") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.ItemsDeducted.Should().Be(1); + result.ItemsSkipped.Should().Be(0); + result.Items.Should().HaveCount(1); + result.Items[0].Deducted.Should().BeTrue(); + result.Items[0].Error.Should().BeNull(); + + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInsufficientStock_ShouldReturnPartialDeduction() + { + // Arrange + var itemId = Guid.NewGuid(); + var inventoryItem = CreateInventoryItemWithStock(itemId, 5); + + _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny())) + .ReturnsAsync(inventoryItem); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: Guid.NewGuid(), + ReferenceType: "KitchenTicket", + Reason: "Test partial deduction", + Items: new List + { + new(itemId, 20, "pcs", "Flour") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Should partially deduct what is available (5 of 20 requested). + // VI: Nen tru mot phan nhung gi co san (5 cua 20 yeu cau). + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.ItemsDeducted.Should().Be(1); + result.Items[0].Deducted.Should().BeTrue(); + result.Items[0].Error.Should().Contain("Partial deduction"); + } + + [Fact] + public async Task Handle_WithNonExistentItem_ShouldReturnNotFound() + { + // Arrange + var missingItemId = Guid.NewGuid(); + + _repositoryMock.Setup(r => r.GetByIdAsync(missingItemId, It.IsAny())) + .ReturnsAsync((InventoryItem?)null); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: Guid.NewGuid(), + ReferenceType: "KitchenTicket", + Reason: "Test not found", + Items: new List + { + new(missingItemId, 5, "pcs", "Missing Item") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ItemsDeducted.Should().Be(0); + result.ItemsSkipped.Should().Be(1); + result.Items[0].Deducted.Should().BeFalse(); + result.Items[0].Error.Should().Be("Item not found"); + } + + [Fact] + public async Task Handle_WithZeroStockAvailable_ShouldSkipDeduction() + { + // Arrange + var itemId = Guid.NewGuid(); + var inventoryItem = CreateInventoryItemWithStock(itemId, 0); + + _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny())) + .ReturnsAsync(inventoryItem); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: Guid.NewGuid(), + ReferenceType: "KitchenTicket", + Reason: "Test zero stock", + Items: new List + { + new(itemId, 10, "pcs", "Empty Item") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Item with 0 stock and insufficient stock should be skipped. + // VI: Item voi 0 stock va khong du stock nen duoc bo qua. + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ItemsSkipped.Should().Be(1); + result.Items[0].Deducted.Should().BeFalse(); + result.Items[0].Error.Should().Contain("Insufficient stock (0 available)"); + } + + [Fact] + public async Task Handle_WithDuplicateRequest_ShouldReturnIdempotentResult() + { + // Arrange + var referenceId = Guid.NewGuid(); + + _requestManagerMock.Setup(r => r.ExistAsync(referenceId)) + .ReturnsAsync(true); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: referenceId, + ReferenceType: "KitchenTicket", + Reason: "Duplicate test", + Items: new List + { + new(Guid.NewGuid(), 5, "pcs", "Item A") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Duplicate request should be detected and skipped. + // VI: Request trung lap nen duoc phat hien va bo qua. + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.ItemsDeducted.Should().Be(0); + result.ItemsSkipped.Should().Be(1); + result.Items[0].Error.Should().Be("Duplicate request"); + + // EN: Should not attempt to get inventory items. + // VI: Khong nen co gang lay inventory items. + _repositoryMock.Verify(r => r.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithMultipleItems_ShouldDeductAll() + { + // Arrange + var itemId1 = Guid.NewGuid(); + var itemId2 = Guid.NewGuid(); + var itemId3 = Guid.NewGuid(); + + var item1 = CreateInventoryItemWithStock(itemId1, 50); + var item2 = CreateInventoryItemWithStock(itemId2, 30); + var item3 = CreateInventoryItemWithStock(itemId3, 100); + + _repositoryMock.Setup(r => r.GetByIdAsync(itemId1, It.IsAny())).ReturnsAsync(item1); + _repositoryMock.Setup(r => r.GetByIdAsync(itemId2, It.IsAny())).ReturnsAsync(item2); + _repositoryMock.Setup(r => r.GetByIdAsync(itemId3, It.IsAny())).ReturnsAsync(item3); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: Guid.NewGuid(), + ReferenceType: "KitchenTicket", + Reason: "Multi-item deduction", + Items: new List + { + new(itemId1, 10, "g", "Sugar"), + new(itemId2, 5, "ml", "Milk"), + new(itemId3, 2, "pcs", "Cup") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.ItemsDeducted.Should().Be(3); + result.ItemsSkipped.Should().Be(0); + result.Items.Should().HaveCount(3); + result.Items.Should().AllSatisfy(i => i.Deducted.Should().BeTrue()); + + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithMixedResults_ShouldDeductFoundItemsAndSkipMissing() + { + // Arrange + var existingItemId = Guid.NewGuid(); + var missingItemId = Guid.NewGuid(); + + var existingItem = CreateInventoryItemWithStock(existingItemId, 50); + + _repositoryMock.Setup(r => r.GetByIdAsync(existingItemId, It.IsAny())) + .ReturnsAsync(existingItem); + _repositoryMock.Setup(r => r.GetByIdAsync(missingItemId, It.IsAny())) + .ReturnsAsync((InventoryItem?)null); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: Guid.NewGuid(), + ReferenceType: "KitchenTicket", + Reason: "Mixed test", + Items: new List + { + new(existingItemId, 5, "pcs", "Sugar"), + new(missingItemId, 3, "ml", "Missing Ingredient") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Should deduct existing item and skip missing one. + // VI: Nen tru item ton tai va bo qua item khong tim thay. + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.ItemsDeducted.Should().Be(1); + result.ItemsSkipped.Should().Be(1); + } + + [Fact] + public async Task Handle_WithItemExceptionDuringDeduction_ShouldContinueWithRemainingItems() + { + // Arrange + var faultyItemId = Guid.NewGuid(); + var goodItemId = Guid.NewGuid(); + + var goodItem = CreateInventoryItemWithStock(goodItemId, 50); + + _repositoryMock.Setup(r => r.GetByIdAsync(faultyItemId, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Database timeout")); + _repositoryMock.Setup(r => r.GetByIdAsync(goodItemId, It.IsAny())) + .ReturnsAsync(goodItem); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: Guid.NewGuid(), + ReferenceType: "KitchenTicket", + Reason: "Error resilience test", + Items: new List + { + new(faultyItemId, 5, "pcs", "Faulty Item"), + new(goodItemId, 3, "pcs", "Good Item") + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Should handle error for faulty item but still deduct good item. + // VI: Nen xu ly loi cho item loi nhung van tru item tot. + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.ItemsDeducted.Should().Be(1); + result.ItemsSkipped.Should().Be(1); + result.Items[0].Deducted.Should().BeFalse(); + result.Items[0].Error.Should().Be("Database timeout"); + result.Items[1].Deducted.Should().BeTrue(); + } + + [Fact] + public void Constructor_WithNullRepository_ShouldThrowArgumentNullException() + { + // Act + var action = () => new DeductInventoryCommandHandler( + null!, + _requestManagerMock.Object, + _loggerMock.Object); + + // Assert + action.Should().Throw() + .And.ParamName.Should().Be("repository"); + } + + [Fact] + public void Constructor_WithNullRequestManager_ShouldThrowArgumentNullException() + { + // Act + var action = () => new DeductInventoryCommandHandler( + _repositoryMock.Object, + null!, + _loggerMock.Object); + + // Assert + action.Should().Throw() + .And.ParamName.Should().Be("requestManager"); + } + + [Fact] + public void Constructor_WithNullLogger_ShouldThrowArgumentNullException() + { + // Act + var action = () => new DeductInventoryCommandHandler( + _repositoryMock.Object, + _requestManagerMock.Object, + null!); + + // Assert + action.Should().Throw() + .And.ParamName.Should().Be("logger"); + } + + [Fact] + public async Task Handle_ShouldRecordIdempotencyRequest() + { + // Arrange + var referenceId = Guid.NewGuid(); + var itemId = Guid.NewGuid(); + var inventoryItem = CreateInventoryItemWithStock(itemId, 50); + + _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny())) + .ReturnsAsync(inventoryItem); + + var command = new DeductInventoryCommand( + ShopId: Guid.NewGuid(), + ReferenceId: referenceId, + ReferenceType: "KitchenTicket", + Reason: "Idempotency test", + Items: new List + { + new(itemId, 5, "pcs", "Item A") + }); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Should check and record the idempotency request. + // VI: Nen kiem tra va ghi nhan idempotency request. + _requestManagerMock.Verify(r => r.ExistAsync(referenceId), Times.Once); + _requestManagerMock.Verify( + r => r.CreateRequestForCommandAsync(referenceId), Times.Once); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Validations/AdminCommandValidators.cs b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AdminCommandValidators.cs new file mode 100644 index 00000000..543accbf --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AdminCommandValidators.cs @@ -0,0 +1,103 @@ +// EN: Validators for Admin commands (approve, reject, suspend, reactivate, ban merchant). +// VI: Validators cho cac commands Admin (duyet, tu choi, tam dinh chi, kich hoat lai, cam merchant). + +using FluentValidation; +using MerchantService.API.Application.Commands.Admin; + +namespace MerchantService.API.Application.Validations; + +/// +/// EN: Validator for ApproveMerchantCommand. +/// VI: Validator cho ApproveMerchantCommand. +/// +public class ApproveMerchantCommandValidator : AbstractValidator +{ + public ApproveMerchantCommandValidator() + { + RuleFor(x => x.MerchantId) + .NotEmpty() + .WithMessage("Merchant ID is required / Merchant ID la bat buoc"); + + RuleFor(x => x.ApprovedBy) + .NotEmpty() + .WithMessage("Approver ID is required / ID nguoi duyet la bat buoc"); + } +} + +/// +/// EN: Validator for RejectMerchantCommand. +/// VI: Validator cho RejectMerchantCommand. +/// +public class RejectMerchantCommandValidator : AbstractValidator +{ + public RejectMerchantCommandValidator() + { + RuleFor(x => x.MerchantId) + .NotEmpty() + .WithMessage("Merchant ID is required / Merchant ID la bat buoc"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("Rejection reason is required / Ly do tu choi la bat buoc") + .MaximumLength(1000) + .WithMessage("Rejection reason must not exceed 1000 characters / Ly do tu choi khong vuot qua 1000 ky tu"); + + RuleFor(x => x.RejectedBy) + .NotEmpty() + .WithMessage("Rejector ID is required / ID nguoi tu choi la bat buoc"); + } +} + +/// +/// EN: Validator for SuspendMerchantCommand. +/// VI: Validator cho SuspendMerchantCommand. +/// +public class SuspendMerchantCommandValidator : AbstractValidator +{ + public SuspendMerchantCommandValidator() + { + RuleFor(x => x.MerchantId) + .NotEmpty() + .WithMessage("Merchant ID is required / Merchant ID la bat buoc"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("Suspension reason is required / Ly do tam dinh chi la bat buoc") + .MaximumLength(1000) + .WithMessage("Suspension reason must not exceed 1000 characters / Ly do tam dinh chi khong vuot qua 1000 ky tu"); + } +} + +/// +/// EN: Validator for ReactivateMerchantCommand. +/// VI: Validator cho ReactivateMerchantCommand. +/// +public class ReactivateMerchantCommandValidator : AbstractValidator +{ + public ReactivateMerchantCommandValidator() + { + RuleFor(x => x.MerchantId) + .NotEmpty() + .WithMessage("Merchant ID is required / Merchant ID la bat buoc"); + } +} + +/// +/// EN: Validator for BanMerchantCommand. +/// VI: Validator cho BanMerchantCommand. +/// +public class BanMerchantCommandValidator : AbstractValidator +{ + public BanMerchantCommandValidator() + { + RuleFor(x => x.MerchantId) + .NotEmpty() + .WithMessage("Merchant ID is required / Merchant ID la bat buoc"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("Ban reason is required / Ly do cam la bat buoc") + .MaximumLength(1000) + .WithMessage("Ban reason must not exceed 1000 characters / Ly do cam khong vuot qua 1000 ky tu"); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Validations/AttendanceCommandValidators.cs b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AttendanceCommandValidators.cs new file mode 100644 index 00000000..65cd9bf4 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AttendanceCommandValidators.cs @@ -0,0 +1,173 @@ +// EN: Validators for Attendance and Leave Request commands. +// VI: Validators cho cac commands Cham cong va Nghi phep. + +using FluentValidation; +using MerchantService.API.Application.Commands.Attendance; +using MerchantService.API.Application.Commands.LeaveRequests; +using MerchantService.API.Application.Commands.Shops; + +namespace MerchantService.API.Application.Validations; + +/// +/// EN: Validator for CheckInCommand. +/// VI: Validator cho CheckInCommand. +/// +public class CheckInCommandValidator : AbstractValidator +{ + public CheckInCommandValidator() + { + RuleFor(x => x.StaffId) + .NotEmpty() + .WithMessage("Staff ID is required / Staff ID la bat buoc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + } +} + +/// +/// EN: Validator for CheckOutCommand. +/// VI: Validator cho CheckOutCommand. +/// +public class CheckOutCommandValidator : AbstractValidator +{ + public CheckOutCommandValidator() + { + RuleFor(x => x.StaffId) + .NotEmpty() + .WithMessage("Staff ID is required / Staff ID la bat buoc"); + } +} + +/// +/// EN: Validator for CreateLeaveRequestCommand. +/// VI: Validator cho CreateLeaveRequestCommand. +/// +public class CreateLeaveRequestCommandValidator : AbstractValidator +{ + private static readonly string[] ValidLeaveTypes = ["Annual", "Sick", "Personal", "Maternity", "Paternity", "Unpaid"]; + + public CreateLeaveRequestCommandValidator() + { + RuleFor(x => x.StaffId) + .NotEmpty() + .WithMessage("Staff ID is required / Staff ID la bat buoc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + + RuleFor(x => x.LeaveType) + .NotEmpty() + .WithMessage("Leave type is required / Loai nghi phep la bat buoc") + .MaximumLength(50) + .WithMessage("Leave type must not exceed 50 characters / Loai nghi phep khong vuot qua 50 ky tu"); + + RuleFor(x => x.StartDate) + .NotEmpty() + .WithMessage("Start date is required / Ngay bat dau la bat buoc"); + + RuleFor(x => x.EndDate) + .NotEmpty() + .WithMessage("End date is required / Ngay ket thuc la bat buoc") + .GreaterThanOrEqualTo(x => x.StartDate) + .WithMessage("End date must be on or after start date / Ngay ket thuc phai bang hoac sau ngay bat dau"); + + RuleFor(x => x.Reason) + .MaximumLength(1000) + .WithMessage("Reason must not exceed 1000 characters / Ly do khong vuot qua 1000 ky tu") + .When(x => x.Reason != null); + } +} + +/// +/// EN: Validator for ApproveLeaveRequestCommand. +/// VI: Validator cho ApproveLeaveRequestCommand. +/// +public class ApproveLeaveRequestCommandValidator : AbstractValidator +{ + public ApproveLeaveRequestCommandValidator() + { + RuleFor(x => x.LeaveRequestId) + .NotEmpty() + .WithMessage("Leave request ID is required / ID yeu cau nghi phep la bat buoc"); + + RuleFor(x => x.ApprovedBy) + .NotEmpty() + .WithMessage("Approver ID is required / ID nguoi duyet la bat buoc"); + } +} + +/// +/// EN: Validator for RejectLeaveRequestCommand. +/// VI: Validator cho RejectLeaveRequestCommand. +/// +public class RejectLeaveRequestCommandValidator : AbstractValidator +{ + public RejectLeaveRequestCommandValidator() + { + RuleFor(x => x.LeaveRequestId) + .NotEmpty() + .WithMessage("Leave request ID is required / ID yeu cau nghi phep la bat buoc"); + + RuleFor(x => x.RejectedBy) + .NotEmpty() + .WithMessage("Rejector ID is required / ID nguoi tu choi la bat buoc"); + + RuleFor(x => x.Reason) + .MaximumLength(1000) + .WithMessage("Reason must not exceed 1000 characters / Ly do khong vuot qua 1000 ky tu") + .When(x => x.Reason != null); + } +} + +/// +/// EN: Validator for UpdateShopCommand. +/// VI: Validator cho UpdateShopCommand. +/// +public class UpdateShopCommandValidator : AbstractValidator +{ + public UpdateShopCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID la bat buoc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Shop name is required / Ten shop la bat buoc") + .MaximumLength(100) + .WithMessage("Shop name must not exceed 100 characters / Ten shop khong vuot qua 100 ky tu"); + + RuleFor(x => x.Description) + .MaximumLength(2000) + .WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu") + .When(x => x.Description != null); + + RuleFor(x => x.Phone) + .MaximumLength(20) + .WithMessage("Phone must not exceed 20 characters / So dien thoai khong vuot qua 20 ky tu") + .When(x => x.Phone != null); + + RuleFor(x => x.Email) + .EmailAddress() + .WithMessage("Invalid email format / Dinh dang email khong hop le") + .When(x => !string.IsNullOrEmpty(x.Email)); + + RuleFor(x => x.Website) + .MaximumLength(200) + .WithMessage("Website must not exceed 200 characters / Website khong vuot qua 200 ky tu") + .When(x => x.Website != null); + + RuleFor(x => x.LogoUrl) + .MaximumLength(2048) + .WithMessage("Logo URL must not exceed 2048 characters / URL logo khong vuot qua 2048 ky tu") + .When(x => x.LogoUrl != null); + + RuleFor(x => x.CoverImageUrl) + .MaximumLength(2048) + .WithMessage("Cover image URL must not exceed 2048 characters / URL anh bia khong vuot qua 2048 ky tu") + .When(x => x.CoverImageUrl != null); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/Reports/CloseDayCommand.cs b/services/order-service-net/src/OrderService.API/Application/Commands/Reports/CloseDayCommand.cs new file mode 100644 index 00000000..efe02537 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Commands/Reports/CloseDayCommand.cs @@ -0,0 +1,101 @@ +// EN: Command to close the business day and generate final report. +// VI: Command dong ngay kinh doanh va tao bao cao cuoi cung. + +using System.Data; +using Dapper; +using MediatR; +using OrderService.API.Application.Queries.Reports; + +namespace OrderService.API.Application.Commands.Reports; + +/// +/// EN: Command to close the business day for a shop and generate the final EOD report. +/// VI: Command dong ngay kinh doanh cho shop va tao bao cao cuoi ngay cuoi cung. +/// +public record CloseDayCommand( + Guid ShopId, + DateTime CloseDate +) : IRequest; + +/// +/// EN: Result of day close operation with final report. +/// VI: Ket qua thao tac dong ngay voi bao cao cuoi cung. +/// +public record CloseDayResult( + bool Success, + EodReportDto? Report, + string? Message, + int PendingOrderCount = 0 +); + +/// +/// EN: Handler for CloseDayCommand — validates no pending orders and generates final EOD report. +/// VI: Handler cho CloseDayCommand — kiem tra khong co don hang cho xu ly va tao bao cao cuoi ngay. +/// +public class CloseDayCommandHandler : IRequestHandler +{ + private readonly IDbConnection _connection; + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public CloseDayCommandHandler( + IDbConnection connection, + IMediator mediator, + ILogger logger) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CloseDayCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Closing day for shop {ShopId} on {Date} / VI: Dong ngay cho shop {ShopId} ngay {Date}", + request.ShopId, request.CloseDate.ToString("yyyy-MM-dd")); + + var dayStart = request.CloseDate.Date; + var dayEnd = dayStart.AddDays(1); + + var parameters = new DynamicParameters(); + parameters.Add("ShopId", request.ShopId); + parameters.Add("DayStart", dayStart); + parameters.Add("DayEnd", dayEnd); + + // EN: Check for pending/in-progress orders (status: Draft=1, Validated=2, Paid=3, Processing=4, PaymentPending=7) + // VI: Kiem tra don hang dang cho/dang xu ly (trang thai: Draft=1, Validated=2, Paid=3, Processing=4, PaymentPending=7) + var pendingSql = @" + SELECT COUNT(*) + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @DayStart + AND o.created_at < @DayEnd + AND o.status_id IN (1, 2, 3, 4, 7)"; + + var pendingCount = await _connection.ExecuteScalarAsync(pendingSql, parameters); + + string? warningMessage = null; + if (pendingCount > 0) + { + warningMessage = $"EN: Warning: {pendingCount} order(s) are still pending/in-progress. / " + + $"VI: Canh bao: {pendingCount} don hang van dang cho xu ly."; + _logger.LogWarning( + "EN: Closing day with {Count} pending orders for shop {ShopId} / VI: Dong ngay voi {Count} don hang dang cho cho shop {ShopId}", + pendingCount, request.ShopId); + } + + // EN: Generate the EOD report via the query handler / VI: Tao bao cao cuoi ngay qua query handler + var report = await _mediator.Send(new GetEodReportQuery(request.ShopId, request.CloseDate), cancellationToken); + + _logger.LogInformation( + "EN: Day closed for shop {ShopId} — {Orders} orders, {Revenue:C} revenue / " + + "VI: Da dong ngay cho shop {ShopId} — {Orders} don hang, {Revenue:C} doanh thu", + request.ShopId, report.TotalOrders, report.TotalRevenue); + + return new CloseDayResult( + Success: true, + Report: report, + Message: warningMessage, + PendingOrderCount: pendingCount); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetEodReportQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetEodReportQuery.cs new file mode 100644 index 00000000..b6ea8cf7 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetEodReportQuery.cs @@ -0,0 +1,201 @@ +// EN: Query for End-of-Day report data. +// VI: Query lay du lieu bao cao cuoi ngay. + +using System.Data; +using Dapper; +using MediatR; + +namespace OrderService.API.Application.Queries.Reports; + +/// +/// EN: Query for End-of-Day report aggregating orders, revenue, payments, and top items. +/// VI: Query cho bao cao cuoi ngay tong hop don hang, doanh thu, thanh toan, va san pham ban chay. +/// +public record GetEodReportQuery( + Guid ShopId, + DateTime ReportDate +) : IRequest; + +/// +/// EN: End-of-Day report DTO with complete daily summary. +/// VI: DTO bao cao cuoi ngay voi tom tat day du trong ngay. +/// +public record EodReportDto( + DateTime ReportDate, + Guid ShopId, + int TotalOrders, + int CompletedOrders, + int CancelledOrders, + decimal TotalRevenue, + decimal CashRevenue, + decimal CardRevenue, + decimal OnlineRevenue, + decimal DiscountTotal, + List PaymentBreakdown, + List TopItems, + List HourlyRevenue +); + +/// +/// EN: Payment breakdown by method (class for Dapper compatibility). +/// VI: Phan tich thanh toan theo phuong thuc (class cho tuong thich Dapper). +/// +public class EodPaymentBreakdownDto +{ + public string Method { get; set; } = string.Empty; + public int Count { get; set; } + public decimal Amount { get; set; } +} + +/// +/// EN: Top selling item for the day (class for Dapper compatibility). +/// VI: San pham ban chay trong ngay (class cho tuong thich Dapper). +/// +public class EodTopItemDto +{ + public string ItemName { get; set; } = string.Empty; + public int Quantity { get; set; } + public decimal Revenue { get; set; } +} + +/// +/// EN: Revenue data per hour (class for Dapper compatibility). +/// VI: Du lieu doanh thu theo gio (class cho tuong thich Dapper). +/// +public class EodHourlyRevenueDto +{ + public int Hour { get; set; } + public int OrderCount { get; set; } + public decimal Revenue { get; set; } +} + +/// +/// EN: Handler for GetEodReportQuery — aggregates daily order data using Dapper. +/// VI: Handler cho GetEodReportQuery — tong hop du lieu don hang hang ngay bang Dapper. +/// +public class GetEodReportQueryHandler : IRequestHandler +{ + private readonly IDbConnection _connection; + private readonly ILogger _logger; + + public GetEodReportQueryHandler(IDbConnection connection, ILogger logger) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(GetEodReportQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Generating EOD report for shop {ShopId} on {Date} / VI: Tao bao cao cuoi ngay cho shop {ShopId} ngay {Date}", + request.ShopId, request.ReportDate.ToString("yyyy-MM-dd")); + + var dayStart = request.ReportDate.Date; + var dayEnd = dayStart.AddDays(1); + + var parameters = new DynamicParameters(); + parameters.Add("ShopId", request.ShopId); + parameters.Add("DayStart", dayStart); + parameters.Add("DayEnd", dayEnd); + + // EN: Completed status ID = 5, Cancelled status ID = 6 (from OrderStatus enumeration) + // VI: ID trang thai hoan thanh = 5, ID trang thai huy = 6 (tu OrderStatus enumeration) + parameters.Add("CompletedStatusId", 5); + parameters.Add("CancelledStatusId", 6); + + // EN: Aggregate order stats for the day / VI: Thong ke tong hop don hang trong ngay + var aggregateSql = @" + SELECT + COUNT(*) AS TotalOrders, + COUNT(*) FILTER (WHERE o.status_id = @CompletedStatusId) AS CompletedOrders, + COUNT(*) FILTER (WHERE o.status_id = @CancelledStatusId) AS CancelledOrders, + COALESCE(SUM(o.total_amount) FILTER (WHERE o.status_id NOT IN (@CancelledStatusId)), 0) AS TotalRevenue, + COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method = 'cash' AND o.status_id NOT IN (@CancelledStatusId)), 0) AS CashRevenue, + COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method = 'card' AND o.status_id NOT IN (@CancelledStatusId)), 0) AS CardRevenue, + COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method NOT IN ('cash', 'card') AND o.payment_method IS NOT NULL AND o.status_id NOT IN (@CancelledStatusId)), 0) AS OnlineRevenue, + COALESCE(SUM(o.discount_amount) FILTER (WHERE o.status_id NOT IN (@CancelledStatusId)), 0) AS DiscountTotal + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @DayStart + AND o.created_at < @DayEnd"; + + var aggregate = await _connection.QuerySingleAsync(aggregateSql, parameters); + + // EN: Payment breakdown by method / VI: Phan tich theo phuong thuc thanh toan + var paymentSql = @" + SELECT + COALESCE(o.payment_method, 'unknown') AS Method, + COUNT(*) AS Count, + COALESCE(SUM(o.total_amount), 0) AS Amount + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @DayStart + AND o.created_at < @DayEnd + AND o.status_id NOT IN (@CancelledStatusId) + GROUP BY o.payment_method + ORDER BY Amount DESC"; + + var paymentBreakdown = (await _connection.QueryAsync(paymentSql, parameters)).AsList(); + + // EN: Top 10 items by quantity sold / VI: Top 10 san pham theo so luong ban + var topItemsSql = @" + SELECT + oi.product_name AS ItemName, + SUM(oi.quantity) AS Quantity, + SUM(oi.unit_price * oi.quantity) AS Revenue + FROM order_items oi + INNER JOIN orders o ON o.id = oi.order_id + WHERE o.shop_id = @ShopId + AND o.created_at >= @DayStart + AND o.created_at < @DayEnd + AND o.status_id NOT IN (@CancelledStatusId) + GROUP BY oi.product_name + ORDER BY Quantity DESC + LIMIT 10"; + + var topItems = (await _connection.QueryAsync(topItemsSql, parameters)).AsList(); + + // EN: Hourly revenue breakdown / VI: Doanh thu theo tung gio + var hourlySql = @" + SELECT + EXTRACT(HOUR FROM o.created_at)::int AS Hour, + COUNT(*) AS OrderCount, + COALESCE(SUM(o.total_amount), 0) AS Revenue + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @DayStart + AND o.created_at < @DayEnd + AND o.status_id NOT IN (@CancelledStatusId) + GROUP BY EXTRACT(HOUR FROM o.created_at) + ORDER BY Hour"; + + var hourlyRevenue = (await _connection.QueryAsync(hourlySql, parameters)).AsList(); + + return new EodReportDto( + request.ReportDate.Date, + request.ShopId, + (int)aggregate.TotalOrders, + (int)aggregate.CompletedOrders, + (int)aggregate.CancelledOrders, + aggregate.TotalRevenue, + aggregate.CashRevenue, + aggregate.CardRevenue, + aggregate.OnlineRevenue, + aggregate.DiscountTotal, + paymentBreakdown, + topItems, + hourlyRevenue); + } + + private record AggregateRow + { + public long TotalOrders { get; init; } + public long CompletedOrders { get; init; } + public long CancelledOrders { get; init; } + public decimal TotalRevenue { get; init; } + public decimal CashRevenue { get; init; } + public decimal CardRevenue { get; init; } + public decimal OnlineRevenue { get; init; } + public decimal DiscountTotal { get; init; } + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CloseDayCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CloseDayCommandValidator.cs new file mode 100644 index 00000000..3d9339d9 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/CloseDayCommandValidator.cs @@ -0,0 +1,27 @@ +// EN: Validator for CloseDayCommand. +// VI: Validator cho CloseDayCommand. + +using FluentValidation; +using OrderService.API.Application.Commands.Reports; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for CloseDayCommand. +/// VI: Validator cho CloseDayCommand. +/// +public class CloseDayCommandValidator : AbstractValidator +{ + public CloseDayCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc"); + + RuleFor(x => x.CloseDate) + .NotEmpty() + .WithMessage("EN: Close date is required / VI: Ngay dong la bat buoc") + .LessThanOrEqualTo(DateTime.UtcNow.Date.AddDays(1)) + .WithMessage("EN: Close date cannot be in the future / VI: Ngay dong khong the trong tuong lai"); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderCommandValidator.cs new file mode 100644 index 00000000..05b77bfb --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderCommandValidator.cs @@ -0,0 +1,25 @@ +// EN: Validator for CompleteOrderCommand. +// VI: Validator cho CompleteOrderCommand. + +using FluentValidation; +using OrderService.API.Application.Commands; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for CompleteOrderCommand. +/// VI: Validator cho CompleteOrderCommand. +/// +public class CompleteOrderCommandValidator : AbstractValidator +{ + public CompleteOrderCommandValidator() + { + RuleFor(x => x.OrderId) + .NotEmpty() + .WithMessage("EN: Order ID is required / VI: Order ID la bat buoc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc"); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderPaymentCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderPaymentCommandValidator.cs new file mode 100644 index 00000000..aae3d673 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderPaymentCommandValidator.cs @@ -0,0 +1,32 @@ +// EN: Validator for CompleteOrderPaymentCommand. +// VI: Validator cho CompleteOrderPaymentCommand. + +using FluentValidation; +using OrderService.API.Application.Commands; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for CompleteOrderPaymentCommand. +/// VI: Validator cho CompleteOrderPaymentCommand. +/// +public class CompleteOrderPaymentCommandValidator : AbstractValidator +{ + public CompleteOrderPaymentCommandValidator() + { + RuleFor(x => x.OrderId) + .NotEmpty() + .WithMessage("EN: Order ID is required / VI: Order ID la bat buoc"); + + RuleFor(x => x.GatewayTransactionId) + .NotEmpty() + .WithMessage("EN: Gateway transaction ID is required / VI: Ma giao dich cong thanh toan la bat buoc") + .MaximumLength(200) + .WithMessage("EN: Gateway transaction ID must not exceed 200 characters / VI: Ma giao dich khong vuot qua 200 ky tu"); + + RuleFor(x => x.GatewayResponseCode) + .MaximumLength(50) + .WithMessage("EN: Gateway response code must not exceed 50 characters / VI: Ma phan hoi khong vuot qua 50 ky tu") + .When(x => x.GatewayResponseCode != null); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/GetEodReportQueryValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/GetEodReportQueryValidator.cs new file mode 100644 index 00000000..c61080e6 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/GetEodReportQueryValidator.cs @@ -0,0 +1,27 @@ +// EN: Validator for GetEodReportQuery. +// VI: Validator cho GetEodReportQuery. + +using FluentValidation; +using OrderService.API.Application.Queries.Reports; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for GetEodReportQuery. +/// VI: Validator cho GetEodReportQuery. +/// +public class GetEodReportQueryValidator : AbstractValidator +{ + public GetEodReportQueryValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc"); + + RuleFor(x => x.ReportDate) + .NotEmpty() + .WithMessage("EN: Report date is required / VI: Ngay bao cao la bat buoc") + .LessThanOrEqualTo(DateTime.UtcNow.Date.AddDays(1)) + .WithMessage("EN: Report date cannot be in the future / VI: Ngay bao cao khong the trong tuong lai"); + } +} diff --git a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs index 2c8ea8a4..47da16b3 100644 --- a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs +++ b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs @@ -3,7 +3,9 @@ using MediatR; using Microsoft.AspNetCore.Mvc; +using OrderService.API.Application.Commands.Reports; using OrderService.API.Application.Queries; +using OrderService.API.Application.Queries.Reports; namespace OrderService.API.Controllers; @@ -71,4 +73,61 @@ public class ReportsController : ControllerBase return Ok(result); } + + /// + /// EN: Get End-of-Day report for a shop on a specific date. + /// VI: Lay bao cao cuoi ngay cho shop vao ngay cu the. + /// + [HttpGet("eod")] + [ProducesResponseType(typeof(EodReportDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetEodReport( + [FromQuery] Guid shopId, + [FromQuery] DateTime? date = null, + CancellationToken cancellationToken = default) + { + var reportDate = date ?? DateTime.UtcNow.Date; + + _logger.LogInformation( + "EN: Getting EOD report for shop {ShopId} on {Date} / VI: Lay bao cao cuoi ngay cho shop {ShopId} ngay {Date}", + shopId, reportDate.ToString("yyyy-MM-dd")); + + var query = new GetEodReportQuery(shopId, reportDate); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new { success = true, data = result }); + } + + /// + /// EN: Close the business day and generate final EOD report. + /// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung. + /// + [HttpPost("close-day")] + [ProducesResponseType(typeof(CloseDayResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CloseDay( + [FromBody] CloseDayRequest request, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Closing day for shop {ShopId} on {Date} / VI: Dong ngay cho shop {ShopId} ngay {Date}", + request.ShopId, request.CloseDate?.ToString("yyyy-MM-dd") ?? "today"); + + var closeDate = request.CloseDate ?? DateTime.UtcNow.Date; + var command = new CloseDayCommand(request.ShopId, closeDate); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(new { success = false, error = new { code = "CLOSE_DAY_FAILED", message = result.Message } }); + } + + return Ok(new { success = true, data = result }); + } } + +/// +/// EN: Request to close the business day. +/// VI: Yeu cau dong ngay kinh doanh. +/// +public record CloseDayRequest(Guid ShopId, DateTime? CloseDate = null); diff --git a/services/storage-service-net/src/StorageService.API/Application/Validations/StorageCommandValidators.cs b/services/storage-service-net/src/StorageService.API/Application/Validations/StorageCommandValidators.cs new file mode 100644 index 00000000..4ba6574d --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Validations/StorageCommandValidators.cs @@ -0,0 +1,226 @@ +// EN: Validators for Storage commands (upload, delete, share, admin). +// VI: Validators cho cac commands Storage (upload, xoa, chia se, admin). + +using FluentValidation; +using StorageService.API.Application.Commands; +using StorageService.API.Application.Commands.Admin; +using StorageService.API.Application.Commands.FileShare; + +namespace StorageService.API.Application.Validations; + +/// +/// EN: Validator for UploadFileCommand. +/// VI: Validator cho UploadFileCommand. +/// +public class UploadFileCommandValidator : AbstractValidator +{ + private static readonly long MaxFileSizeBytes = 500L * 1024 * 1024; // 500 MB + + public UploadFileCommandValidator() + { + RuleFor(x => x.FileName) + .NotEmpty() + .WithMessage("File name is required / Ten file la bat buoc") + .MaximumLength(255) + .WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu") + .Must(name => !name.Contains("..") && !name.Contains('/') && !name.Contains('\\')) + .WithMessage("File name contains invalid characters / Ten file chua ky tu khong hop le"); + + RuleFor(x => x.ContentType) + .NotEmpty() + .WithMessage("Content type is required / Content type la bat buoc") + .MaximumLength(100) + .WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu"); + + RuleFor(x => x.FileSizeBytes) + .GreaterThan(0) + .WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0") + .LessThanOrEqualTo(MaxFileSizeBytes) + .WithMessage("File size exceeds maximum limit of 500 MB / Kich thuoc file vuot qua gioi han 500 MB"); + + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc") + .MaximumLength(128) + .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu"); + + RuleFor(x => x.TenantId) + .MaximumLength(128) + .WithMessage("Tenant ID must not exceed 128 characters / Tenant ID khong vuot qua 128 ky tu") + .When(x => x.TenantId != null); + } +} + +/// +/// EN: Validator for SignUploadCommand. +/// VI: Validator cho SignUploadCommand. +/// +public class SignUploadCommandValidator : AbstractValidator +{ + private static readonly long MaxFileSizeBytes = 500L * 1024 * 1024; // 500 MB + + public SignUploadCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc") + .MaximumLength(128) + .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu"); + + RuleFor(x => x.FileName) + .NotEmpty() + .WithMessage("File name is required / Ten file la bat buoc") + .MaximumLength(255) + .WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu") + .Must(name => !name.Contains("..") && !name.Contains('/') && !name.Contains('\\')) + .WithMessage("File name contains invalid characters / Ten file chua ky tu khong hop le"); + + RuleFor(x => x.ContentType) + .NotEmpty() + .WithMessage("Content type is required / Content type la bat buoc") + .MaximumLength(100) + .WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu"); + + RuleFor(x => x.FileSizeBytes) + .GreaterThan(0) + .WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0") + .LessThanOrEqualTo(MaxFileSizeBytes) + .WithMessage("File size exceeds maximum limit of 500 MB / Kich thuoc file vuot qua gioi han 500 MB"); + + RuleFor(x => x.TenantId) + .MaximumLength(128) + .WithMessage("Tenant ID must not exceed 128 characters / Tenant ID khong vuot qua 128 ky tu") + .When(x => x.TenantId != null); + } +} + +/// +/// EN: Validator for ConfirmUploadCommand. +/// VI: Validator cho ConfirmUploadCommand. +/// +public class ConfirmUploadCommandValidator : AbstractValidator +{ + public ConfirmUploadCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc") + .MaximumLength(128) + .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu"); + + RuleFor(x => x.ObjectKey) + .NotEmpty() + .WithMessage("Object key is required / Object key la bat buoc") + .MaximumLength(1024) + .WithMessage("Object key must not exceed 1024 characters / Object key khong vuot qua 1024 ky tu"); + + RuleFor(x => x.FileName) + .NotEmpty() + .WithMessage("File name is required / Ten file la bat buoc") + .MaximumLength(255) + .WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu"); + + RuleFor(x => x.ContentType) + .NotEmpty() + .WithMessage("Content type is required / Content type la bat buoc") + .MaximumLength(100) + .WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu"); + + RuleFor(x => x.FileSizeBytes) + .GreaterThan(0) + .WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0"); + } +} + +/// +/// EN: Validator for DeleteFileCommand. +/// VI: Validator cho DeleteFileCommand. +/// +public class DeleteFileCommandValidator : AbstractValidator +{ + public DeleteFileCommandValidator() + { + RuleFor(x => x.FileId) + .NotEmpty() + .WithMessage("File ID is required / File ID la bat buoc"); + + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc") + .MaximumLength(128) + .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu"); + } +} + +/// +/// EN: Validator for CreateFileShareCommand. +/// VI: Validator cho CreateFileShareCommand. +/// +public class CreateFileShareCommandValidator : AbstractValidator +{ + public CreateFileShareCommandValidator() + { + RuleFor(x => x.FileId) + .NotEmpty() + .WithMessage("File ID is required / File ID la bat buoc"); + + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc") + .MaximumLength(128) + .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu"); + + RuleFor(x => x.SharedWith) + .MaximumLength(256) + .WithMessage("Shared with must not exceed 256 characters / Chia se voi khong vuot qua 256 ky tu") + .When(x => x.SharedWith != null); + + RuleFor(x => x.Password) + .MinimumLength(4) + .WithMessage("Password must be at least 4 characters / Mat khau phai it nhat 4 ky tu") + .MaximumLength(100) + .WithMessage("Password must not exceed 100 characters / Mat khau khong vuot qua 100 ky tu") + .When(x => x.Password != null); + + RuleFor(x => x.MaxDownloads) + .GreaterThan(0) + .WithMessage("Max downloads must be greater than zero / So luot tai toi da phai lon hon 0") + .LessThanOrEqualTo(10000) + .WithMessage("Max downloads must not exceed 10000 / So luot tai toi da khong vuot qua 10000") + .When(x => x.MaxDownloads.HasValue); + + RuleFor(x => x.ExpiresAt) + .GreaterThan(DateTime.UtcNow) + .WithMessage("Expiration time must be in the future / Thoi gian het han phai trong tuong lai") + .When(x => x.ExpiresAt.HasValue); + } +} + +/// +/// EN: Validator for UpdateUserQuotaCommand. +/// VI: Validator cho UpdateUserQuotaCommand. +/// +public class UpdateUserQuotaCommandValidator : AbstractValidator +{ + public UpdateUserQuotaCommandValidator() + { + RuleFor(x => x.TargetUserId) + .NotEmpty() + .WithMessage("Target user ID is required / User ID muc tieu la bat buoc") + .MaximumLength(128) + .WithMessage("Target user ID must not exceed 128 characters / User ID muc tieu khong vuot qua 128 ky tu"); + + RuleFor(x => x.MaxStorageBytes) + .GreaterThan(0) + .WithMessage("Max storage must be greater than zero / Dung luong toi da phai lon hon 0"); + + RuleFor(x => x.MaxFileCount) + .GreaterThan(0) + .WithMessage("Max file count must be greater than zero / So file toi da phai lon hon 0"); + + RuleFor(x => x.QuotaTier) + .MaximumLength(50) + .WithMessage("Quota tier must not exceed 50 characters / Goi quota khong vuot qua 50 ky tu") + .When(x => x.QuotaTier != null); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Validations/PointCommandValidators.cs b/services/wallet-service-net/src/WalletService.API/Application/Validations/PointCommandValidators.cs new file mode 100644 index 00000000..53b9691e --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Validations/PointCommandValidators.cs @@ -0,0 +1,88 @@ +// EN: Validators for Point commands (earn, spend, create account). +// VI: Validators cho cac commands Point (tich diem, tieu diem, tao tai khoan). + +using FluentValidation; +using WalletService.API.Application.Commands; + +namespace WalletService.API.Application.Validations; + +/// +/// EN: Validator for CreatePointAccountCommand. +/// VI: Validator cho CreatePointAccountCommand. +/// +public class CreatePointAccountCommandValidator : AbstractValidator +{ + public CreatePointAccountCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + } +} + +/// +/// EN: Validator for EarnPointsCommand. +/// VI: Validator cho EarnPointsCommand. +/// +public class EarnPointsCommandValidator : AbstractValidator +{ + public EarnPointsCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.Points) + .GreaterThan(0) + .WithMessage("Points must be greater than zero / Diem phai lon hon 0"); + + RuleFor(x => x.Source) + .NotEmpty() + .WithMessage("Source is required / Nguon la bat buoc") + .MaximumLength(100) + .WithMessage("Source must not exceed 100 characters / Nguon khong vuot qua 100 ky tu"); + + RuleFor(x => x.Description) + .NotEmpty() + .WithMessage("Description is required / Mo ta la bat buoc") + .MaximumLength(500) + .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu"); + + RuleFor(x => x.ExpiryMonths) + .GreaterThan(0) + .WithMessage("Expiry months must be greater than zero / So thang het han phai lon hon 0") + .LessThanOrEqualTo(120) + .WithMessage("Expiry months must not exceed 120 / So thang het han khong vuot qua 120") + .When(x => x.ExpiryMonths.HasValue); + } +} + +/// +/// EN: Validator for SpendPointsCommand. +/// VI: Validator cho SpendPointsCommand. +/// +public class SpendPointsCommandValidator : AbstractValidator +{ + public SpendPointsCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.Points) + .GreaterThan(0) + .WithMessage("Points must be greater than zero / Diem phai lon hon 0"); + + RuleFor(x => x.Source) + .NotEmpty() + .WithMessage("Source is required / Nguon la bat buoc") + .MaximumLength(100) + .WithMessage("Source must not exceed 100 characters / Nguon khong vuot qua 100 ky tu"); + + RuleFor(x => x.Description) + .NotEmpty() + .WithMessage("Description is required / Mo ta la bat buoc") + .MaximumLength(500) + .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu"); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Validations/WalletCommandValidators.cs b/services/wallet-service-net/src/WalletService.API/Application/Validations/WalletCommandValidators.cs new file mode 100644 index 00000000..02188ef4 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Validations/WalletCommandValidators.cs @@ -0,0 +1,122 @@ +// EN: Validators for Wallet commands (deposit, withdraw, create wallet, exchange). +// VI: Validators cho cac commands Wallet (nap tien, rut tien, tao vi, quy doi). + +using FluentValidation; +using WalletService.API.Application.Commands; + +namespace WalletService.API.Application.Validations; + +/// +/// EN: Validator for CreateWalletCommand. +/// VI: Validator cho CreateWalletCommand. +/// +public class CreateWalletCommandValidator : AbstractValidator +{ + public CreateWalletCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.Currency) + .NotEmpty() + .WithMessage("Currency is required / Tien te la bat buoc") + .MaximumLength(10) + .WithMessage("Currency code must not exceed 10 characters / Ma tien te khong vuot qua 10 ky tu"); + } +} + +/// +/// EN: Validator for DepositCommand. +/// VI: Validator cho DepositCommand. +/// +public class DepositCommandValidator : AbstractValidator +{ + public DepositCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Amount must be greater than zero / So tien phai lon hon 0") + .LessThanOrEqualTo(1_000_000_000m) + .WithMessage("Amount exceeds maximum limit / So tien vuot qua gioi han toi da"); + + RuleFor(x => x.Description) + .NotEmpty() + .WithMessage("Description is required / Mo ta la bat buoc") + .MaximumLength(500) + .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu"); + + RuleFor(x => x.ReferenceId) + .MaximumLength(100) + .WithMessage("Reference ID must not exceed 100 characters / Ma tham chieu khong vuot qua 100 ky tu") + .When(x => x.ReferenceId != null); + } +} + +/// +/// EN: Validator for WithdrawCommand. +/// VI: Validator cho WithdrawCommand. +/// +public class WithdrawCommandValidator : AbstractValidator +{ + public WithdrawCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Amount must be greater than zero / So tien phai lon hon 0") + .LessThanOrEqualTo(1_000_000_000m) + .WithMessage("Amount exceeds maximum limit / So tien vuot qua gioi han toi da"); + + RuleFor(x => x.Description) + .NotEmpty() + .WithMessage("Description is required / Mo ta la bat buoc") + .MaximumLength(500) + .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu"); + + RuleFor(x => x.ReferenceId) + .MaximumLength(100) + .WithMessage("Reference ID must not exceed 100 characters / Ma tham chieu khong vuot qua 100 ky tu") + .When(x => x.ReferenceId != null); + } +} + +/// +/// EN: Validator for ExchangeCommand. +/// VI: Validator cho ExchangeCommand. +/// +public class ExchangeCommandValidator : AbstractValidator +{ + public ExchangeCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("User ID is required / User ID la bat buoc"); + + RuleFor(x => x.FromAmount) + .GreaterThan(0) + .WithMessage("From amount must be greater than zero / So tien quy doi phai lon hon 0"); + + RuleFor(x => x.FromCurrencyTypeId) + .GreaterThan(0) + .WithMessage("From currency type is required / Loai tien nguon la bat buoc"); + + RuleFor(x => x.ToCurrencyTypeId) + .GreaterThan(0) + .WithMessage("To currency type is required / Loai tien dich la bat buoc") + .NotEqual(x => x.FromCurrencyTypeId) + .WithMessage("Cannot exchange to same currency type / Khong the quy doi cung loai tien te"); + + RuleFor(x => x.CustomRate) + .GreaterThan(0) + .WithMessage("Custom rate must be greater than zero / Ty gia tuy chinh phai lon hon 0") + .When(x => x.CustomRate.HasValue); + } +} diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/CreatePaymentCommandHandlerTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/CreatePaymentCommandHandlerTests.cs new file mode 100644 index 00000000..f2bc87be --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/CreatePaymentCommandHandlerTests.cs @@ -0,0 +1,251 @@ +// EN: Unit tests for CreatePaymentCommandHandler - payment creation with gateway integration. +// VI: Unit tests cho CreatePaymentCommandHandler - tao thanh toan voi tich hop cong thanh toan. + +using FluentAssertions; +using MediatR; +using Moq; +using WalletService.API.Application.Commands.Payments; +using WalletService.Domain.AggregatesModel.PaymentAggregate; +using WalletService.Domain.Events; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace WalletService.UnitTests.Application.Commands; + +/// +/// EN: Unit tests for CreatePaymentCommandHandler. +/// VI: Unit tests cho CreatePaymentCommandHandler. +/// +public class CreatePaymentCommandHandlerTests +{ + private readonly Mock _paymentRepositoryMock; + private readonly Mock _vnpayGatewayMock; + private readonly Mock _unitOfWorkMock; + private readonly Mock> _loggerMock; + private readonly CreatePaymentCommandHandler _handler; + + public CreatePaymentCommandHandlerTests() + { + _paymentRepositoryMock = new Mock(); + _vnpayGatewayMock = new Mock(); + _unitOfWorkMock = new Mock(); + _loggerMock = new Mock>(); + + _vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay"); + + _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + _paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object); + _paymentRepositoryMock.Setup(r => r.Add(It.IsAny())) + .Returns((Payment p) => p); + + var gateways = new List { _vnpayGatewayMock.Object }; + + _handler = new CreatePaymentCommandHandler( + _paymentRepositoryMock.Object, + gateways, + _loggerMock.Object); + } + + [Fact] + public async Task Handle_WithValidPayment_ShouldCreatePayment() + { + // Arrange + _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentResult( + Success: true, + TransactionId: "TXN123", + PaymentUrl: "https://vnpay.vn/pay?txn=TXN123")); + + var command = new CreatePaymentCommand( + OrderId: Guid.NewGuid(), + Amount: 100_000m, + Currency: "VND", + GatewayName: "VNPay", + ReturnUrl: "https://goodgo.vn/payment/return", + IpAddress: "127.0.0.1"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.PaymentId.Should().NotBeEmpty(); + result.OrderId.Should().Be(command.OrderId); + result.Amount.Should().Be(100_000m); + result.Currency.Should().Be("VND"); + result.GatewayName.Should().Be("VNPay"); + result.Status.Should().Be("Processing"); + result.PaymentUrl.Should().Be("https://vnpay.vn/pay?txn=TXN123"); + + _paymentRepositoryMock.Verify(r => r.Add(It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidGateway_ShouldThrowWalletDomainException() + { + // Arrange + var command = new CreatePaymentCommand( + OrderId: Guid.NewGuid(), + Amount: 50_000m, + Currency: "VND", + GatewayName: "NonExistentGateway", + ReturnUrl: "https://goodgo.vn/payment/return", + IpAddress: "127.0.0.1"); + + // Act + var action = () => _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Should throw WalletDomainException for unsupported gateway. + // VI: Nen throw WalletDomainException cho cong thanh toan khong ho tro. + await action.Should().ThrowAsync() + .WithMessage("*NonExistentGateway*not supported*"); + } + + [Fact] + public async Task Handle_WithGatewayFailure_ShouldCreatePaymentWithFailedStatus() + { + // Arrange + _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentResult( + Success: false, + ErrorCode: "TIMEOUT", + ErrorMessage: "Gateway timeout")); + + var command = new CreatePaymentCommand( + OrderId: Guid.NewGuid(), + Amount: 200_000m, + Currency: "VND", + GatewayName: "VNPay", + ReturnUrl: "https://goodgo.vn/payment/return", + IpAddress: "127.0.0.1"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Should create the payment entity but mark it as failed. + // VI: Nen tao payment entity nhung danh dau la that bai. + result.Should().NotBeNull(); + result.Status.Should().Be("Failed"); + result.PaymentUrl.Should().BeNull(); + + _paymentRepositoryMock.Verify(r => r.Add(It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public void Handle_WithZeroAmount_ShouldThrowWalletDomainException() + { + // Arrange + // EN: Payment entity constructor validates amount > 0. + // VI: Constructor cua Payment entity validate amount > 0. + var command = new CreatePaymentCommand( + OrderId: Guid.NewGuid(), + Amount: 0m, + Currency: "VND", + GatewayName: "VNPay", + ReturnUrl: "https://goodgo.vn/payment/return", + IpAddress: "127.0.0.1"); + + _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay")); + + // Act + var action = () => _handler.Handle(command, CancellationToken.None); + + // Assert + action.Should().ThrowAsync() + .WithMessage("*amount*greater than zero*"); + } + + [Fact] + public void Handle_WithNegativeAmount_ShouldThrowWalletDomainException() + { + // Arrange + var command = new CreatePaymentCommand( + OrderId: Guid.NewGuid(), + Amount: -100_000m, + Currency: "VND", + GatewayName: "VNPay", + ReturnUrl: "https://goodgo.vn/payment/return", + IpAddress: "127.0.0.1"); + + _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay")); + + // Act + var action = () => _handler.Handle(command, CancellationToken.None); + + // Assert + action.Should().ThrowAsync() + .WithMessage("*amount*greater than zero*"); + } + + [Fact] + public async Task Handle_ShouldRaiseDomainEvent() + { + // Arrange + _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentResult( + Success: true, + TransactionId: "TXN456", + PaymentUrl: "https://vnpay.vn/pay?txn=TXN456")); + + Payment? capturedPayment = null; + _paymentRepositoryMock.Setup(r => r.Add(It.IsAny())) + .Callback(p => capturedPayment = p) + .Returns((Payment p) => p); + + var command = new CreatePaymentCommand( + OrderId: Guid.NewGuid(), + Amount: 150_000m, + Currency: "VND", + GatewayName: "VNPay", + ReturnUrl: "https://goodgo.vn/payment/return", + IpAddress: "127.0.0.1"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Payment constructor raises PaymentCreatedDomainEvent. + // VI: Constructor cua Payment phat ra PaymentCreatedDomainEvent. + capturedPayment.Should().NotBeNull(); + capturedPayment!.DomainEvents.Should().NotBeEmpty(); + capturedPayment.DomainEvents.Should().ContainItemsAssignableTo(); + } + + [Fact] + public async Task Handle_WithSuccessfulGateway_ShouldSetPaymentUrlAndTransactionId() + { + // Arrange + var expectedUrl = "https://vnpay.vn/pay?txn=ABC789"; + var expectedTxnId = "ABC789"; + + _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentResult( + Success: true, + TransactionId: expectedTxnId, + PaymentUrl: expectedUrl)); + + var command = new CreatePaymentCommand( + OrderId: Guid.NewGuid(), + Amount: 300_000m, + Currency: "VND", + GatewayName: "VNPay", + ReturnUrl: "https://goodgo.vn/payment/return", + IpAddress: "192.168.1.1"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.PaymentUrl.Should().Be(expectedUrl); + result.Status.Should().Be("Processing"); + } +} diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs new file mode 100644 index 00000000..0b160e01 --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs @@ -0,0 +1,243 @@ +// EN: Unit tests for ProcessPaymentCallbackCommandHandler - payment callback (IPN) processing. +// VI: Unit tests cho ProcessPaymentCallbackCommandHandler - xu ly callback (IPN) thanh toan. + +using FluentAssertions; +using Moq; +using WalletService.API.Application.Commands.Payments; +using WalletService.Domain.AggregatesModel.PaymentAggregate; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace WalletService.UnitTests.Application.Commands; + +/// +/// EN: Unit tests for ProcessPaymentCallbackCommandHandler. +/// VI: Unit tests cho ProcessPaymentCallbackCommandHandler. +/// +public class ProcessPaymentCallbackCommandHandlerTests +{ + private readonly Mock _paymentRepositoryMock; + private readonly Mock _vnpayGatewayMock; + private readonly Mock _unitOfWorkMock; + private readonly Mock> _loggerMock; + private readonly ProcessPaymentCallbackCommandHandler _handler; + + public ProcessPaymentCallbackCommandHandlerTests() + { + _paymentRepositoryMock = new Mock(); + _vnpayGatewayMock = new Mock(); + _unitOfWorkMock = new Mock(); + _loggerMock = new Mock>(); + + _vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay"); + + _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + _paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object); + + var gateways = new List { _vnpayGatewayMock.Object }; + + _handler = new ProcessPaymentCallbackCommandHandler( + _paymentRepositoryMock.Object, + gateways, + _loggerMock.Object); + } + + /// + /// EN: Helper to create a Payment entity in Processing state for callback testing. + /// VI: Helper de tao Payment entity o trang thai Processing de test callback. + /// + private static Payment CreateProcessingPayment(Guid orderId) + { + var payment = new Payment(orderId, 100_000m, "VND", "VNPay"); + payment.MarkAsProcessing("https://vnpay.vn/pay", "TXN_ORIGINAL"); + return payment; + } + + [Fact] + public async Task Handle_WithValidCallback_ShouldUpdatePaymentStatusToCompleted() + { + // Arrange + var orderId = Guid.NewGuid(); + var payment = CreateProcessingPayment(orderId); + + _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny())) + .Returns(true); + _paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId)) + .ReturnsAsync(payment); + + var parameters = new Dictionary + { + { "vnp_SecureHash", "valid_hash_123" }, + { "vnp_TxnRef", orderId.ToString() }, + { "vnp_ResponseCode", "00" }, + { "vnp_TransactionNo", "VNP_TXN_001" } + }; + + var command = new ProcessPaymentCallbackCommand("VNPay", parameters); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.PaymentId.Should().Be(payment.Id); + result.OrderId.Should().Be(orderId); + result.Status.Should().Be("Completed"); + result.ErrorMessage.Should().BeNull(); + + _paymentRepositoryMock.Verify(r => r.Update(payment), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidSignature_ShouldRejectCallback() + { + // Arrange + _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny())) + .Returns(false); + + var parameters = new Dictionary + { + { "vnp_SecureHash", "invalid_hash" }, + { "vnp_TxnRef", Guid.NewGuid().ToString() }, + { "vnp_ResponseCode", "00" } + }; + + var command = new ProcessPaymentCallbackCommand("VNPay", parameters); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Invalid signature should be rejected without updating any payment. + // VI: Chu ky khong hop le nen bi tu choi ma khong cap nhat payment nao. + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Status.Should().Be("InvalidSignature"); + result.ErrorMessage.Should().Contain("Invalid callback signature"); + + _paymentRepositoryMock.Verify(r => r.GetByOrderIdAsync(It.IsAny()), Times.Never); + _paymentRepositoryMock.Verify(r => r.Update(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithNonExistentPayment_ShouldReturnNotFound() + { + // Arrange + var orderId = Guid.NewGuid(); + + _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny())) + .Returns(true); + _paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId)) + .ReturnsAsync((Payment?)null); + + var parameters = new Dictionary + { + { "vnp_SecureHash", "valid_hash" }, + { "vnp_TxnRef", orderId.ToString() }, + { "vnp_ResponseCode", "00" }, + { "vnp_TransactionNo", "VNP_TXN_002" } + }; + + var command = new ProcessPaymentCallbackCommand("VNPay", parameters); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.PaymentId.Should().BeNull(); + result.OrderId.Should().Be(orderId); + result.Status.Should().Be("NotFound"); + result.ErrorMessage.Should().Contain("Payment not found"); + } + + [Fact] + public async Task Handle_WithFailedResponseCode_ShouldUpdatePaymentToFailed() + { + // Arrange + var orderId = Guid.NewGuid(); + var payment = CreateProcessingPayment(orderId); + + _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny())) + .Returns(true); + _paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId)) + .ReturnsAsync(payment); + + var parameters = new Dictionary + { + { "vnp_SecureHash", "valid_hash" }, + { "vnp_TxnRef", orderId.ToString() }, + { "vnp_ResponseCode", "24" }, + { "vnp_TransactionNo", "" }, + { "vnp_OrderInfo", "Customer cancelled payment" } + }; + + var command = new ProcessPaymentCallbackCommand("VNPay", parameters); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + // EN: Failed response code should mark payment as failed. + // VI: Ma phan hoi that bai nen danh dau payment la that bai. + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Status.Should().Be("Failed"); + result.ErrorMessage.Should().Contain("24"); + + _paymentRepositoryMock.Verify(r => r.Update(payment), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidTransactionReference_ShouldReturnInvalidReference() + { + // Arrange + _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny())) + .Returns(true); + + var parameters = new Dictionary + { + { "vnp_SecureHash", "valid_hash" }, + { "vnp_TxnRef", "not-a-valid-guid" }, + { "vnp_ResponseCode", "00" } + }; + + var command = new ProcessPaymentCallbackCommand("VNPay", parameters); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Status.Should().Be("InvalidReference"); + result.ErrorMessage.Should().Contain("Invalid transaction reference"); + } + + [Fact] + public async Task Handle_WithUnsupportedGateway_ShouldThrowWalletDomainException() + { + // Arrange + var parameters = new Dictionary + { + { "vnp_SecureHash", "hash" }, + { "vnp_TxnRef", Guid.NewGuid().ToString() } + }; + + var command = new ProcessPaymentCallbackCommand("MoMo", parameters); + + // Act + var action = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await action.Should().ThrowAsync() + .WithMessage("*MoMo*not supported*"); + } +}