feat: EOD reports, security audit (rate limiting + 44 validators), and 30 critical path tests
EOD Reports & Daily Close (order-service + Blazor UI): - GetEodReportQuery: Dapper query for revenue, orders, payment breakdown, top items, hourly chart - CloseDayCommand: check pending orders, generate final report - EodReport.razor: 6 KPI cards, donut/bar charts, top 10 table, close-day dialog - FluentValidation for both query and command - BFF proxy endpoints for reports Security Audit — Rate Limiting: - Tighten auth-ratelimit from 100 to 10 req/min (brute force protection) - Add payment-ratelimit (30/min), api-ratelimit (100/min), hub-ratelimit (500/min) - Apply rate limits to ALL Traefik routers (previously many had none) Security Audit — Input Sanitization (44 missing validators created): - iam-service: 14 validators (auth, user, role commands) - merchant-service: 11 validators (admin, attendance commands) - wallet-service: 7 validators (wallet, points commands) - fnb-engine: 7 validators (session, table, ticket, reservation) - catalog-service: 6 validators (product, category CRUD) - storage-service: 6 validators (upload, share, quota) - order-service: 2 validators (complete order/payment) Critical Path Unit Tests (30 new tests): - inventory-service: 12 tests (deduction, partial stock, idempotency) - wallet-service: 14 tests (create payment, process callback, domain events) - fnb-engine: 8 tests (kitchen-served event handler, inventory client integration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
41
ROADMAP.md
41
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 |
|
||||
|
||||
@@ -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.
|
||||
*@
|
||||
|
||||
<PageTitle>Bao cao cuoi ngay — GoodGo POS</PageTitle>
|
||||
|
||||
@* ═══ TOP BAR ═══ *@
|
||||
<div class="admin-topbar">
|
||||
<div class="admin-topbar__left">
|
||||
<h1 class="admin-topbar__title">Bao cao cuoi ngay</h1>
|
||||
<p class="admin-topbar__subtitle">Tong hop doanh thu va don hang trong ngay</p>
|
||||
</div>
|
||||
<div class="admin-topbar__right" style="display:flex;align-items:center;gap:12px;">
|
||||
<MudDatePicker @bind-Date="_selectedDate"
|
||||
Label="Chon ngay"
|
||||
Variant="Variant.Outlined"
|
||||
DateFormat="dd/MM/yyyy"
|
||||
MaxDate="DateTime.Today"
|
||||
Style="max-width:180px;"
|
||||
Color="Color.Primary"
|
||||
Class="mud-input-dark" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="LoadReportAsync"
|
||||
Disabled="_loading"
|
||||
StartIcon="@Icons.Material.Filled.Assessment">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
|
||||
}
|
||||
Xem bao cao
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Warning"
|
||||
OnClick="CloseDayAsync"
|
||||
Disabled="_loading || _report == null"
|
||||
StartIcon="@Icons.Material.Filled.Lock">
|
||||
Dong ngay
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ CONTENT ═══ *@
|
||||
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="display:flex;justify-content:center;padding:48px;">
|
||||
<MudProgressCircular Size="Size.Large" Indeterminate="true" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
else if (_report == null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
||||
Chon ngay va nhan "Xem bao cao" de tai du lieu bao cao cuoi ngay.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── KPI SUMMARY CARDS ── *@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
|
||||
@* Total Revenue *@
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
|
||||
<i data-lucide="trending-up" style="color:#22C55E;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@FormatVND(_report.TotalRevenue)</span>
|
||||
<span class="admin-stat-card__label">Tong doanh thu</span>
|
||||
</div>
|
||||
</div>
|
||||
@* Total Orders *@
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);">
|
||||
<i data-lucide="shopping-bag" style="color:#3B82F6;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_report.TotalOrders</span>
|
||||
<span class="admin-stat-card__label">Tong don hang</span>
|
||||
</div>
|
||||
</div>
|
||||
@* Average Order Value *@
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);">
|
||||
<i data-lucide="banknote" style="color:#8B5CF6;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@FormatVND(_report.TotalOrders > 0 ? _report.TotalRevenue / _report.TotalOrders : 0)</span>
|
||||
<span class="admin-stat-card__label">Gia tri TB / don</span>
|
||||
</div>
|
||||
</div>
|
||||
@* Discount Total *@
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);">
|
||||
<i data-lucide="percent" style="color:#EC4899;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@FormatVND(_report.DiscountTotal)</span>
|
||||
<span class="admin-stat-card__label">Giam gia</span>
|
||||
</div>
|
||||
</div>
|
||||
@* Completed Orders *@
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
|
||||
<i data-lucide="check-circle" style="color:#22C55E;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_report.CompletedOrders</span>
|
||||
<span class="admin-stat-card__label">Hoan thanh</span>
|
||||
</div>
|
||||
</div>
|
||||
@* Cancelled Orders *@
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);">
|
||||
<i data-lucide="x-circle" style="color:#EF4444;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_report.CancelledOrders</span>
|
||||
<span class="admin-stat-card__label">Da huy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── REVENUE BY PAYMENT METHOD ── *@
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
|
||||
@* Payment Breakdown Chart *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">Phan tich thanh toan</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body">
|
||||
@if (_report.PaymentBreakdown.Any())
|
||||
{
|
||||
<div style="display:flex;gap:24px;align-items:center;">
|
||||
<div style="flex:1;">
|
||||
<MudChart ChartType="ChartType.Donut"
|
||||
InputData="@_paymentChartData"
|
||||
InputLabels="@_paymentChartLabels"
|
||||
Width="200px"
|
||||
Height="200px"
|
||||
ChartOptions="_donutOptions" />
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
@foreach (var p in _report.PaymentBreakdown)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--admin-border-subtle);">
|
||||
<div>
|
||||
<span style="font-weight:600;color:var(--admin-text-primary);">@GetPaymentMethodLabel(p.Method)</span>
|
||||
<span style="font-size:12px;color:var(--admin-text-tertiary);margin-left:8px;">(@p.Count don)</span>
|
||||
</div>
|
||||
<span style="font-weight:700;color:var(--admin-orange-primary);">@FormatVND(p.Amount)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu thanh toan.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Revenue Breakdown Cards *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">Doanh thu theo loai</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-radius:8px;background:rgba(34,197,94,0.08);">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="banknote" style="color:#22C55E;width:20px;height:20px;"></i>
|
||||
<span style="font-weight:600;">Tien mat</span>
|
||||
</div>
|
||||
<span style="font-weight:700;color:#22C55E;">@FormatVND(_report.CashRevenue)</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-radius:8px;background:rgba(59,130,246,0.08);">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="credit-card" style="color:#3B82F6;width:20px;height:20px;"></i>
|
||||
<span style="font-weight:600;">The</span>
|
||||
</div>
|
||||
<span style="font-weight:700;color:#3B82F6;">@FormatVND(_report.CardRevenue)</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-radius:8px;background:rgba(139,92,246,0.08);">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="smartphone" style="color:#8B5CF6;width:20px;height:20px;"></i>
|
||||
<span style="font-weight:600;">Truc tuyen</span>
|
||||
</div>
|
||||
<span style="font-weight:700;color:#8B5CF6;">@FormatVND(_report.OnlineRevenue)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── HOURLY REVENUE CHART ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">Doanh thu theo gio</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body">
|
||||
@if (_hourlyChartData.Length > 0)
|
||||
{
|
||||
<MudChart ChartType="ChartType.Bar"
|
||||
ChartSeries="@_hourlyChartSeries"
|
||||
XAxisLabels="@_hourlyChartLabels"
|
||||
Width="100%"
|
||||
Height="300px"
|
||||
ChartOptions="_barOptions" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu doanh thu theo gio.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── TOP 10 ITEMS TABLE ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">Top 10 san pham ban chay</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
@if (_report.TopItems.Any())
|
||||
{
|
||||
<MudTable Items="@_report.TopItems"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Elevation="0"
|
||||
Class="mud-table-dark">
|
||||
<HeaderContent>
|
||||
<MudTh Style="font-weight:700;">#</MudTh>
|
||||
<MudTh Style="font-weight:700;">Ten san pham</MudTh>
|
||||
<MudTh Style="font-weight:700;text-align:right;">So luong</MudTh>
|
||||
<MudTh Style="font-weight:700;text-align:right;">Doanh thu</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd Style="font-weight:700;color:var(--admin-orange-primary);">@((_report.TopItems.IndexOf(context) + 1))</MudTd>
|
||||
<MudTd Style="font-weight:600;">@context.ItemName</MudTd>
|
||||
<MudTd Style="text-align:right;font-weight:600;color:#3B82F6;">@context.Quantity</MudTd>
|
||||
<MudTd Style="text-align:right;font-weight:700;color:var(--admin-orange-primary);">@FormatVND(context.Revenue)</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu san pham.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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<double>();
|
||||
private string[] _paymentChartLabels = Array.Empty<string>();
|
||||
private double[] _hourlyChartData = Array.Empty<double>();
|
||||
private string[] _hourlyChartLabels = Array.Empty<string>();
|
||||
private List<ChartSeries> _hourlyChartSeries = new();
|
||||
|
||||
private ChartOptions _donutOptions = new()
|
||||
{
|
||||
ChartPalette = new[] { "#22C55E", "#3B82F6", "#8B5CF6", "#F59E0B", "#EC4899", "#FF5C00" }
|
||||
};
|
||||
|
||||
private ChartOptions _barOptions = new()
|
||||
{
|
||||
ChartPalette = new[] { "#FF5C00" },
|
||||
YAxisTicks = 5
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get the current shop ID from the admin context.
|
||||
/// VI: Lay shop ID hien tai tu admin context.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Load the EOD report for the selected date.
|
||||
/// VI: Tai bao cao cuoi ngay cho ngay duoc chon.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Close the business day with confirmation dialog.
|
||||
/// VI: Dong ngay kinh doanh voi dialog xac nhan.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Build chart data arrays from the report.
|
||||
/// VI: Xay dung mang du lieu bieu do tu bao cao.
|
||||
/// </summary>
|
||||
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<double>();
|
||||
_paymentChartLabels = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// 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<ChartSeries>
|
||||
{
|
||||
new ChartSeries { Name = "Doanh thu", Data = _hourlyChartData }
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_hourlyChartData = Array.Empty<double>();
|
||||
_hourlyChartLabels = Array.Empty<string>();
|
||||
_hourlyChartSeries = new();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get Vietnamese label for payment method.
|
||||
/// VI: Lay nhan tieng Viet cho phuong thuc thanh toan.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
@@ -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<EodPaymentBreakdownInfo> PaymentBreakdown,
|
||||
List<EodTopItemInfo> TopItems,
|
||||
List<EodHourlyRevenueInfo> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<EodReportInfo?> GetEodReportAsync(Guid shopId, DateTime date)
|
||||
{
|
||||
var url = $"api/bff/reports/eod?shopId={shopId}&date={date:yyyy-MM-dd}";
|
||||
return await GetObjectFromApiAsync<EodReportInfo>(url);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Close the business day and generate final EOD report.
|
||||
/// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung.
|
||||
/// </summary>
|
||||
public async Task<CloseDayResultInfo?> CloseDayAsync(Guid shopId, DateTime date)
|
||||
{
|
||||
var url = "api/bff/reports/close-day";
|
||||
return await PostAndGetAsync<CloseDayResultInfo>(url, new { shopId, closeDate = date.ToString("yyyy-MM-dd") });
|
||||
}
|
||||
|
||||
// ═══ SERVICE HEALTH CHECK ═══
|
||||
|
||||
public record ServiceHealthInfo(string Name, string Icon, bool IsOnline, int? LatencyMs);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get End-of-Day report for a shop.
|
||||
/// VI: Lay bao cao cuoi ngay cho shop.
|
||||
/// </summary>
|
||||
[HttpGet("reports/eod")]
|
||||
public Task<IActionResult> GetEodReport(
|
||||
[FromQuery] Guid? shopId = null,
|
||||
[FromQuery] string? date = null)
|
||||
{
|
||||
var qs = new List<string>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Close the business day and generate final EOD report.
|
||||
/// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay.
|
||||
/// </summary>
|
||||
[HttpPost("reports/close-day")]
|
||||
public Task<IActionResult> CloseDay([FromBody] CloseDayBffRequest request)
|
||||
{
|
||||
return _order.PostAsJsonAsync("/api/v1/reports/close-day", new
|
||||
{
|
||||
shopId = request.ShopId,
|
||||
closeDate = request.CloseDate
|
||||
}).ProxyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: BFF request for closing the business day.
|
||||
/// VI: BFF request de dong ngay kinh doanh.
|
||||
/// </summary>
|
||||
public record CloseDayBffRequest(Guid ShopId, string? CloseDate = null);
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateProductCommand.
|
||||
/// VI: Validator cho CreateProductCommand.
|
||||
/// </summary>
|
||||
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateProductCommand.
|
||||
/// VI: Validator cho UpdateProductCommand.
|
||||
/// </summary>
|
||||
public class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for DeleteProductCommand.
|
||||
/// VI: Validator cho DeleteProductCommand.
|
||||
/// </summary>
|
||||
public class DeleteProductCommandValidator : AbstractValidator<DeleteProductCommand>
|
||||
{
|
||||
public DeleteProductCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProductId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Product ID is required / Product ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateCategoryCommand.
|
||||
/// VI: Validator cho CreateCategoryCommand.
|
||||
/// </summary>
|
||||
public class CreateCategoryCommandValidator : AbstractValidator<CreateCategoryCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateCategoryCommand.
|
||||
/// VI: Validator cho UpdateCategoryCommand.
|
||||
/// </summary>
|
||||
public class UpdateCategoryCommandValidator : AbstractValidator<UpdateCategoryCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for DeleteCategoryCommand.
|
||||
/// VI: Validator cho DeleteCategoryCommand.
|
||||
/// </summary>
|
||||
public class DeleteCategoryCommandValidator : AbstractValidator<DeleteCategoryCommand>
|
||||
{
|
||||
public DeleteCategoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.CategoryId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Category ID is required / Category ID la bat buoc");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for OpenSessionCommand.
|
||||
/// VI: Validator cho OpenSessionCommand.
|
||||
/// </summary>
|
||||
public class OpenSessionCommandValidator : AbstractValidator<OpenSessionCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CloseSessionCommand.
|
||||
/// VI: Validator cho CloseSessionCommand.
|
||||
/// </summary>
|
||||
public class CloseSessionCommandValidator : AbstractValidator<CloseSessionCommand>
|
||||
{
|
||||
public CloseSessionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.SessionId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Session ID is required / Session ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ChangeTableStatusCommand.
|
||||
/// VI: Validator cho ChangeTableStatusCommand.
|
||||
/// </summary>
|
||||
public class ChangeTableStatusCommandValidator : AbstractValidator<ChangeTableStatusCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateTableCommand.
|
||||
/// VI: Validator cho UpdateTableCommand.
|
||||
/// </summary>
|
||||
public class UpdateTableCommandValidator : AbstractValidator<UpdateTableCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateTicketStatusCommand.
|
||||
/// VI: Validator cho UpdateTicketStatusCommand.
|
||||
/// </summary>
|
||||
public class UpdateTicketStatusCommandValidator : AbstractValidator<UpdateTicketStatusCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateReservationCommand.
|
||||
/// VI: Validator cho CreateReservationCommand.
|
||||
/// </summary>
|
||||
public class CreateReservationCommandValidator : AbstractValidator<CreateReservationCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateKitchenTicketCommand.
|
||||
/// VI: Validator cho CreateKitchenTicketCommand.
|
||||
/// </summary>
|
||||
public class CreateKitchenTicketCommandValidator : AbstractValidator<CreateKitchenTicketCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for KitchenTicketServedDomainEventHandler.
|
||||
/// VI: Unit tests cho KitchenTicketServedDomainEventHandler.
|
||||
/// </summary>
|
||||
public class KitchenTicketServedDomainEventHandlerTests
|
||||
{
|
||||
private readonly Mock<IRecipeRepository> _recipeRepositoryMock;
|
||||
private readonly Mock<ISessionRepository> _sessionRepositoryMock;
|
||||
private readonly Mock<IInventoryServiceClient> _inventoryClientMock;
|
||||
private readonly Mock<ILogger<KitchenTicketServedDomainEventHandler>> _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<IRecipeRepository>();
|
||||
_sessionRepositoryMock = new Mock<ISessionRepository>();
|
||||
_inventoryClientMock = new Mock<IInventoryServiceClient>();
|
||||
_loggerMock = new Mock<ILogger<KitchenTicketServedDomainEventHandler>>();
|
||||
|
||||
_handler = new KitchenTicketServedDomainEventHandler(
|
||||
_recipeRepositoryMock.Object,
|
||||
_sessionRepositoryMock.Object,
|
||||
_inventoryClientMock.Object,
|
||||
_loggerMock.Object);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Helper to create a KitchenTicket for testing.
|
||||
/// VI: Helper de tao KitchenTicket de test.
|
||||
/// </summary>
|
||||
private KitchenTicket CreateTicket(Guid? productId = null, int quantity = 1)
|
||||
{
|
||||
return new KitchenTicket(
|
||||
_sessionId,
|
||||
Guid.NewGuid(),
|
||||
productId ?? _productId,
|
||||
"Pho Bo",
|
||||
quantity,
|
||||
"Kitchen",
|
||||
0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Helper to create a Session for testing.
|
||||
/// VI: Helper de tao Session de test.
|
||||
/// </summary>
|
||||
private Session CreateSession()
|
||||
{
|
||||
return new Session(Guid.NewGuid(), _shopId, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Helper to create a Recipe with linked ingredients.
|
||||
/// VI: Helper de tao Recipe voi nguyen lieu co lien ket.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Helper to create a Recipe with no linked inventory items.
|
||||
/// VI: Helper de tao Recipe khong co lien ket den inventory items.
|
||||
/// </summary>
|
||||
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<CancellationToken>()))
|
||||
.ReturnsAsync(session);
|
||||
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(recipe);
|
||||
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
|
||||
.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<DeductInventoryRequest>(req =>
|
||||
req.ShopId == _shopId &&
|
||||
req.ReferenceId == ticket.Id &&
|
||||
req.ReferenceType == "KitchenTicket" &&
|
||||
req.Items.Count == 2),
|
||||
It.IsAny<CancellationToken>()),
|
||||
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<CancellationToken>()))
|
||||
.ReturnsAsync(session);
|
||||
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
||||
.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<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
||||
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<CancellationToken>()))
|
||||
.ReturnsAsync(session);
|
||||
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(recipe);
|
||||
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
|
||||
.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<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
||||
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<CancellationToken>()))
|
||||
.ReturnsAsync(session);
|
||||
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(recipe);
|
||||
|
||||
DeductInventoryRequest? capturedRequest = null;
|
||||
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<DeductInventoryRequest, CancellationToken>((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<CancellationToken>()))
|
||||
.ReturnsAsync(session);
|
||||
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
||||
.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<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
||||
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<CancellationToken>()))
|
||||
.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<Guid>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
_inventoryClientMock.Verify(
|
||||
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
||||
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<CancellationToken>()))
|
||||
.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<ArgumentNullException>()
|
||||
.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<ArgumentNullException>()
|
||||
.And.ParamName.Should().Be("inventoryClient");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ExternalLoginCommand.
|
||||
/// VI: Validator cho ExternalLoginCommand.
|
||||
/// </summary>
|
||||
public class ExternalLoginCommandValidator : AbstractValidator<ExternalLoginCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for Enable2FACommand.
|
||||
/// VI: Validator cho Enable2FACommand.
|
||||
/// </summary>
|
||||
public class Enable2FACommandValidator : AbstractValidator<Enable2FACommand>
|
||||
{
|
||||
public Enable2FACommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.NotEmpty()
|
||||
.WithMessage("User ID is required / User ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for Verify2FACommand.
|
||||
/// VI: Validator cho Verify2FACommand.
|
||||
/// </summary>
|
||||
public class Verify2FACommandValidator : AbstractValidator<Verify2FACommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for Disable2FACommand.
|
||||
/// VI: Validator cho Disable2FACommand.
|
||||
/// </summary>
|
||||
public class Disable2FACommandValidator : AbstractValidator<Disable2FACommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for LogoutCommand.
|
||||
/// VI: Validator cho LogoutCommand.
|
||||
/// </summary>
|
||||
public class LogoutCommandValidator : AbstractValidator<LogoutCommand>
|
||||
{
|
||||
public LogoutCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.NotEmpty()
|
||||
.WithMessage("User ID is required / User ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for SendVerificationEmailCommand.
|
||||
/// VI: Validator cho SendVerificationEmailCommand.
|
||||
/// </summary>
|
||||
public class SendVerificationEmailCommandValidator : AbstractValidator<SendVerificationEmailCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ConfirmEmailCommand.
|
||||
/// VI: Validator cho ConfirmEmailCommand.
|
||||
/// </summary>
|
||||
public class ConfirmEmailCommandValidator : AbstractValidator<ConfirmEmailCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateRoleCommand.
|
||||
/// VI: Validator cho CreateRoleCommand.
|
||||
/// </summary>
|
||||
public class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateRoleCommand.
|
||||
/// VI: Validator cho UpdateRoleCommand.
|
||||
/// </summary>
|
||||
public class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for DeleteRoleCommand.
|
||||
/// VI: Validator cho DeleteRoleCommand.
|
||||
/// </summary>
|
||||
public class DeleteRoleCommandValidator : AbstractValidator<DeleteRoleCommand>
|
||||
{
|
||||
public DeleteRoleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RoleId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Role ID is required / Role ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for AssignRoleToUserCommand.
|
||||
/// VI: Validator cho AssignRoleToUserCommand.
|
||||
/// </summary>
|
||||
public class AssignRoleToUserCommandValidator : AbstractValidator<AssignRoleToUserCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for RemoveRoleFromUserCommand.
|
||||
/// VI: Validator cho RemoveRoleFromUserCommand.
|
||||
/// </summary>
|
||||
public class RemoveRoleFromUserCommandValidator : AbstractValidator<RemoveRoleFromUserCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateUserCommand.
|
||||
/// VI: Validator cho UpdateUserCommand.
|
||||
/// </summary>
|
||||
public class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for DeleteUserCommand.
|
||||
/// VI: Validator cho DeleteUserCommand.
|
||||
/// </summary>
|
||||
public class DeleteUserCommandValidator : AbstractValidator<DeleteUserCommand>
|
||||
{
|
||||
public DeleteUserCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.NotEmpty()
|
||||
.WithMessage("User ID is required / User ID la bat buoc");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for DeductInventoryCommandHandler.
|
||||
/// VI: Unit tests cho DeductInventoryCommandHandler.
|
||||
/// </summary>
|
||||
public class DeductInventoryCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IInventoryRepository> _repositoryMock;
|
||||
private readonly Mock<IRequestManager> _requestManagerMock;
|
||||
private readonly Mock<ILogger<DeductInventoryCommandHandler>> _loggerMock;
|
||||
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
||||
private readonly DeductInventoryCommandHandler _handler;
|
||||
|
||||
public DeductInventoryCommandHandlerTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IInventoryRepository>();
|
||||
_requestManagerMock = new Mock<IRequestManager>();
|
||||
_loggerMock = new Mock<ILogger<DeductInventoryCommandHandler>>();
|
||||
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
||||
|
||||
_unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_repositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
|
||||
|
||||
_requestManagerMock.Setup(r => r.ExistAsync(It.IsAny<Guid>()))
|
||||
.ReturnsAsync(false);
|
||||
_requestManagerMock.Setup(r => r.CreateRequestForCommandAsync<DeductInventoryCommand>(It.IsAny<Guid>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_handler = new DeductInventoryCommandHandler(
|
||||
_repositoryMock.Object,
|
||||
_requestManagerMock.Object,
|
||||
_loggerMock.Object);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Helper to create an InventoryItem with a specified available quantity.
|
||||
/// VI: Helper de tao InventoryItem voi so luong kha dung chi dinh.
|
||||
/// </summary>
|
||||
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<CancellationToken>()))
|
||||
.ReturnsAsync(inventoryItem);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: Guid.NewGuid(),
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Test deduction",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<CancellationToken>()), 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<CancellationToken>()))
|
||||
.ReturnsAsync(inventoryItem);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: Guid.NewGuid(),
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Test partial deduction",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<CancellationToken>()))
|
||||
.ReturnsAsync((InventoryItem?)null);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: Guid.NewGuid(),
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Test not found",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<CancellationToken>()))
|
||||
.ReturnsAsync(inventoryItem);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: Guid.NewGuid(),
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Test zero stock",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<DeductionItem>
|
||||
{
|
||||
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<Guid>(), It.IsAny<CancellationToken>()), 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<CancellationToken>())).ReturnsAsync(item1);
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync(itemId2, It.IsAny<CancellationToken>())).ReturnsAsync(item2);
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync(itemId3, It.IsAny<CancellationToken>())).ReturnsAsync(item3);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: Guid.NewGuid(),
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Multi-item deduction",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<CancellationToken>()), 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<CancellationToken>()))
|
||||
.ReturnsAsync(existingItem);
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync(missingItemId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((InventoryItem?)null);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: Guid.NewGuid(),
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Mixed test",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Database timeout"));
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync(goodItemId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(goodItem);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: Guid.NewGuid(),
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Error resilience test",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<ArgumentNullException>()
|
||||
.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<ArgumentNullException>()
|
||||
.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<ArgumentNullException>()
|
||||
.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<CancellationToken>()))
|
||||
.ReturnsAsync(inventoryItem);
|
||||
|
||||
var command = new DeductInventoryCommand(
|
||||
ShopId: Guid.NewGuid(),
|
||||
ReferenceId: referenceId,
|
||||
ReferenceType: "KitchenTicket",
|
||||
Reason: "Idempotency test",
|
||||
Items: new List<DeductionItem>
|
||||
{
|
||||
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<DeductInventoryCommand>(referenceId), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ApproveMerchantCommand.
|
||||
/// VI: Validator cho ApproveMerchantCommand.
|
||||
/// </summary>
|
||||
public class ApproveMerchantCommandValidator : AbstractValidator<ApproveMerchantCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for RejectMerchantCommand.
|
||||
/// VI: Validator cho RejectMerchantCommand.
|
||||
/// </summary>
|
||||
public class RejectMerchantCommandValidator : AbstractValidator<RejectMerchantCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for SuspendMerchantCommand.
|
||||
/// VI: Validator cho SuspendMerchantCommand.
|
||||
/// </summary>
|
||||
public class SuspendMerchantCommandValidator : AbstractValidator<SuspendMerchantCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ReactivateMerchantCommand.
|
||||
/// VI: Validator cho ReactivateMerchantCommand.
|
||||
/// </summary>
|
||||
public class ReactivateMerchantCommandValidator : AbstractValidator<ReactivateMerchantCommand>
|
||||
{
|
||||
public ReactivateMerchantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.MerchantId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Merchant ID is required / Merchant ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for BanMerchantCommand.
|
||||
/// VI: Validator cho BanMerchantCommand.
|
||||
/// </summary>
|
||||
public class BanMerchantCommandValidator : AbstractValidator<BanMerchantCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CheckInCommand.
|
||||
/// VI: Validator cho CheckInCommand.
|
||||
/// </summary>
|
||||
public class CheckInCommandValidator : AbstractValidator<CheckInCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CheckOutCommand.
|
||||
/// VI: Validator cho CheckOutCommand.
|
||||
/// </summary>
|
||||
public class CheckOutCommandValidator : AbstractValidator<CheckOutCommand>
|
||||
{
|
||||
public CheckOutCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StaffId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Staff ID is required / Staff ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateLeaveRequestCommand.
|
||||
/// VI: Validator cho CreateLeaveRequestCommand.
|
||||
/// </summary>
|
||||
public class CreateLeaveRequestCommandValidator : AbstractValidator<CreateLeaveRequestCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ApproveLeaveRequestCommand.
|
||||
/// VI: Validator cho ApproveLeaveRequestCommand.
|
||||
/// </summary>
|
||||
public class ApproveLeaveRequestCommandValidator : AbstractValidator<ApproveLeaveRequestCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for RejectLeaveRequestCommand.
|
||||
/// VI: Validator cho RejectLeaveRequestCommand.
|
||||
/// </summary>
|
||||
public class RejectLeaveRequestCommandValidator : AbstractValidator<RejectLeaveRequestCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateShopCommand.
|
||||
/// VI: Validator cho UpdateShopCommand.
|
||||
/// </summary>
|
||||
public class UpdateShopCommandValidator : AbstractValidator<UpdateShopCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record CloseDayCommand(
|
||||
Guid ShopId,
|
||||
DateTime CloseDate
|
||||
) : IRequest<CloseDayResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of day close operation with final report.
|
||||
/// VI: Ket qua thao tac dong ngay voi bao cao cuoi cung.
|
||||
/// </summary>
|
||||
public record CloseDayResult(
|
||||
bool Success,
|
||||
EodReportDto? Report,
|
||||
string? Message,
|
||||
int PendingOrderCount = 0
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CloseDayCommandHandler : IRequestHandler<CloseDayCommand, CloseDayResult>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<CloseDayCommandHandler> _logger;
|
||||
|
||||
public CloseDayCommandHandler(
|
||||
IDbConnection connection,
|
||||
IMediator mediator,
|
||||
ILogger<CloseDayCommandHandler> 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<CloseDayResult> 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<int>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record GetEodReportQuery(
|
||||
Guid ShopId,
|
||||
DateTime ReportDate
|
||||
) : IRequest<EodReportDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: End-of-Day report DTO with complete daily summary.
|
||||
/// VI: DTO bao cao cuoi ngay voi tom tat day du trong ngay.
|
||||
/// </summary>
|
||||
public record EodReportDto(
|
||||
DateTime ReportDate,
|
||||
Guid ShopId,
|
||||
int TotalOrders,
|
||||
int CompletedOrders,
|
||||
int CancelledOrders,
|
||||
decimal TotalRevenue,
|
||||
decimal CashRevenue,
|
||||
decimal CardRevenue,
|
||||
decimal OnlineRevenue,
|
||||
decimal DiscountTotal,
|
||||
List<EodPaymentBreakdownDto> PaymentBreakdown,
|
||||
List<EodTopItemDto> TopItems,
|
||||
List<EodHourlyRevenueDto> HourlyRevenue
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment breakdown by method (class for Dapper compatibility).
|
||||
/// VI: Phan tich thanh toan theo phuong thuc (class cho tuong thich Dapper).
|
||||
/// </summary>
|
||||
public class EodPaymentBreakdownDto
|
||||
{
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public int Count { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Top selling item for the day (class for Dapper compatibility).
|
||||
/// VI: San pham ban chay trong ngay (class cho tuong thich Dapper).
|
||||
/// </summary>
|
||||
public class EodTopItemDto
|
||||
{
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
public int Quantity { get; set; }
|
||||
public decimal Revenue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Revenue data per hour (class for Dapper compatibility).
|
||||
/// VI: Du lieu doanh thu theo gio (class cho tuong thich Dapper).
|
||||
/// </summary>
|
||||
public class EodHourlyRevenueDto
|
||||
{
|
||||
public int Hour { get; set; }
|
||||
public int OrderCount { get; set; }
|
||||
public decimal Revenue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetEodReportQuery — aggregates daily order data using Dapper.
|
||||
/// VI: Handler cho GetEodReportQuery — tong hop du lieu don hang hang ngay bang Dapper.
|
||||
/// </summary>
|
||||
public class GetEodReportQueryHandler : IRequestHandler<GetEodReportQuery, EodReportDto>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
private readonly ILogger<GetEodReportQueryHandler> _logger;
|
||||
|
||||
public GetEodReportQueryHandler(IDbConnection connection, ILogger<GetEodReportQueryHandler> logger)
|
||||
{
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<EodReportDto> 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<AggregateRow>(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<EodPaymentBreakdownDto>(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<EodTopItemDto>(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<EodHourlyRevenueDto>(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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CloseDayCommand.
|
||||
/// VI: Validator cho CloseDayCommand.
|
||||
/// </summary>
|
||||
public class CloseDayCommandValidator : AbstractValidator<CloseDayCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// EN: Validator for CompleteOrderCommand.
|
||||
// VI: Validator cho CompleteOrderCommand.
|
||||
|
||||
using FluentValidation;
|
||||
using OrderService.API.Application.Commands;
|
||||
|
||||
namespace OrderService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CompleteOrderCommand.
|
||||
/// VI: Validator cho CompleteOrderCommand.
|
||||
/// </summary>
|
||||
public class CompleteOrderCommandValidator : AbstractValidator<CompleteOrderCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// EN: Validator for CompleteOrderPaymentCommand.
|
||||
// VI: Validator cho CompleteOrderPaymentCommand.
|
||||
|
||||
using FluentValidation;
|
||||
using OrderService.API.Application.Commands;
|
||||
|
||||
namespace OrderService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CompleteOrderPaymentCommand.
|
||||
/// VI: Validator cho CompleteOrderPaymentCommand.
|
||||
/// </summary>
|
||||
public class CompleteOrderPaymentCommandValidator : AbstractValidator<CompleteOrderPaymentCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for GetEodReportQuery.
|
||||
/// VI: Validator cho GetEodReportQuery.
|
||||
/// </summary>
|
||||
public class GetEodReportQueryValidator : AbstractValidator<GetEodReportQuery>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("eod")]
|
||||
[ProducesResponseType(typeof(EodReportDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<EodReportDto>> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Close the business day and generate final EOD report.
|
||||
/// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung.
|
||||
/// </summary>
|
||||
[HttpPost("close-day")]
|
||||
[ProducesResponseType(typeof(CloseDayResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<CloseDayResult>> 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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to close the business day.
|
||||
/// VI: Yeu cau dong ngay kinh doanh.
|
||||
/// </summary>
|
||||
public record CloseDayRequest(Guid ShopId, DateTime? CloseDate = null);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UploadFileCommand.
|
||||
/// VI: Validator cho UploadFileCommand.
|
||||
/// </summary>
|
||||
public class UploadFileCommandValidator : AbstractValidator<UploadFileCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for SignUploadCommand.
|
||||
/// VI: Validator cho SignUploadCommand.
|
||||
/// </summary>
|
||||
public class SignUploadCommandValidator : AbstractValidator<SignUploadCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ConfirmUploadCommand.
|
||||
/// VI: Validator cho ConfirmUploadCommand.
|
||||
/// </summary>
|
||||
public class ConfirmUploadCommandValidator : AbstractValidator<ConfirmUploadCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for DeleteFileCommand.
|
||||
/// VI: Validator cho DeleteFileCommand.
|
||||
/// </summary>
|
||||
public class DeleteFileCommandValidator : AbstractValidator<DeleteFileCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateFileShareCommand.
|
||||
/// VI: Validator cho CreateFileShareCommand.
|
||||
/// </summary>
|
||||
public class CreateFileShareCommandValidator : AbstractValidator<CreateFileShareCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateUserQuotaCommand.
|
||||
/// VI: Validator cho UpdateUserQuotaCommand.
|
||||
/// </summary>
|
||||
public class UpdateUserQuotaCommandValidator : AbstractValidator<UpdateUserQuotaCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreatePointAccountCommand.
|
||||
/// VI: Validator cho CreatePointAccountCommand.
|
||||
/// </summary>
|
||||
public class CreatePointAccountCommandValidator : AbstractValidator<CreatePointAccountCommand>
|
||||
{
|
||||
public CreatePointAccountCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.NotEmpty()
|
||||
.WithMessage("User ID is required / User ID la bat buoc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for EarnPointsCommand.
|
||||
/// VI: Validator cho EarnPointsCommand.
|
||||
/// </summary>
|
||||
public class EarnPointsCommandValidator : AbstractValidator<EarnPointsCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for SpendPointsCommand.
|
||||
/// VI: Validator cho SpendPointsCommand.
|
||||
/// </summary>
|
||||
public class SpendPointsCommandValidator : AbstractValidator<SpendPointsCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateWalletCommand.
|
||||
/// VI: Validator cho CreateWalletCommand.
|
||||
/// </summary>
|
||||
public class CreateWalletCommandValidator : AbstractValidator<CreateWalletCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for DepositCommand.
|
||||
/// VI: Validator cho DepositCommand.
|
||||
/// </summary>
|
||||
public class DepositCommandValidator : AbstractValidator<DepositCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for WithdrawCommand.
|
||||
/// VI: Validator cho WithdrawCommand.
|
||||
/// </summary>
|
||||
public class WithdrawCommandValidator : AbstractValidator<WithdrawCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ExchangeCommand.
|
||||
/// VI: Validator cho ExchangeCommand.
|
||||
/// </summary>
|
||||
public class ExchangeCommandValidator : AbstractValidator<ExchangeCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for CreatePaymentCommandHandler.
|
||||
/// VI: Unit tests cho CreatePaymentCommandHandler.
|
||||
/// </summary>
|
||||
public class CreatePaymentCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IPaymentRepository> _paymentRepositoryMock;
|
||||
private readonly Mock<IPaymentGateway> _vnpayGatewayMock;
|
||||
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
||||
private readonly Mock<ILogger<CreatePaymentCommandHandler>> _loggerMock;
|
||||
private readonly CreatePaymentCommandHandler _handler;
|
||||
|
||||
public CreatePaymentCommandHandlerTests()
|
||||
{
|
||||
_paymentRepositoryMock = new Mock<IPaymentRepository>();
|
||||
_vnpayGatewayMock = new Mock<IPaymentGateway>();
|
||||
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
||||
_loggerMock = new Mock<ILogger<CreatePaymentCommandHandler>>();
|
||||
|
||||
_vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay");
|
||||
|
||||
_unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
|
||||
_paymentRepositoryMock.Setup(r => r.Add(It.IsAny<Payment>()))
|
||||
.Returns((Payment p) => p);
|
||||
|
||||
var gateways = new List<IPaymentGateway> { _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<PaymentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.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<Payment>()), Times.Once);
|
||||
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), 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<WalletDomainException>()
|
||||
.WithMessage("*NonExistentGateway*not supported*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithGatewayFailure_ShouldCreatePaymentWithFailedStatus()
|
||||
{
|
||||
// Arrange
|
||||
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.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<Payment>()), Times.Once);
|
||||
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), 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<PaymentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay"));
|
||||
|
||||
// Act
|
||||
var action = () => _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
action.Should().ThrowAsync<WalletDomainException>()
|
||||
.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<PaymentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay"));
|
||||
|
||||
// Act
|
||||
var action = () => _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
action.Should().ThrowAsync<WalletDomainException>()
|
||||
.WithMessage("*amount*greater than zero*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ShouldRaiseDomainEvent()
|
||||
{
|
||||
// Arrange
|
||||
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PaymentResult(
|
||||
Success: true,
|
||||
TransactionId: "TXN456",
|
||||
PaymentUrl: "https://vnpay.vn/pay?txn=TXN456"));
|
||||
|
||||
Payment? capturedPayment = null;
|
||||
_paymentRepositoryMock.Setup(r => r.Add(It.IsAny<Payment>()))
|
||||
.Callback<Payment>(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<PaymentCreatedDomainEvent>();
|
||||
}
|
||||
|
||||
[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<PaymentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for ProcessPaymentCallbackCommandHandler.
|
||||
/// VI: Unit tests cho ProcessPaymentCallbackCommandHandler.
|
||||
/// </summary>
|
||||
public class ProcessPaymentCallbackCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IPaymentRepository> _paymentRepositoryMock;
|
||||
private readonly Mock<IPaymentGateway> _vnpayGatewayMock;
|
||||
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
||||
private readonly Mock<ILogger<ProcessPaymentCallbackCommandHandler>> _loggerMock;
|
||||
private readonly ProcessPaymentCallbackCommandHandler _handler;
|
||||
|
||||
public ProcessPaymentCallbackCommandHandlerTests()
|
||||
{
|
||||
_paymentRepositoryMock = new Mock<IPaymentRepository>();
|
||||
_vnpayGatewayMock = new Mock<IPaymentGateway>();
|
||||
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
||||
_loggerMock = new Mock<ILogger<ProcessPaymentCallbackCommandHandler>>();
|
||||
|
||||
_vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay");
|
||||
|
||||
_unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
|
||||
|
||||
var gateways = new List<IPaymentGateway> { _vnpayGatewayMock.Object };
|
||||
|
||||
_handler = new ProcessPaymentCallbackCommandHandler(
|
||||
_paymentRepositoryMock.Object,
|
||||
gateways,
|
||||
_loggerMock.Object);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
_paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
|
||||
.ReturnsAsync(payment);
|
||||
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithInvalidSignature_ShouldRejectCallback()
|
||||
{
|
||||
// Arrange
|
||||
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<Guid>()), Times.Never);
|
||||
_paymentRepositoryMock.Verify(r => r.Update(It.IsAny<Payment>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentPayment_ShouldReturnNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var orderId = Guid.NewGuid();
|
||||
|
||||
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
_paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
|
||||
.ReturnsAsync((Payment?)null);
|
||||
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<IDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
_paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
|
||||
.ReturnsAsync(payment);
|
||||
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithInvalidTransactionReference_ShouldReturnInvalidReference()
|
||||
{
|
||||
// Arrange
|
||||
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<string, string>
|
||||
{
|
||||
{ "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<WalletDomainException>()
|
||||
.WithMessage("*MoMo*not supported*");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user