From 051261accd95cf4b749ebfd1b693297e531be13d Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 4 Mar 2026 20:05:38 +0700 Subject: [PATCH] feat: implement recipe management, inventory operations, voucher integration, and order discounts --- .../Pages/Admin/Shop/ShopPage.razor | 378 ++++++++++++++++-- .../Pages/Pos/Cafe/CafeDesktop.razor | 65 ++- .../Services/PosDataService.cs | 92 ++++- .../Controllers/FinancialController.cs | 38 ++ .../Controllers/InventoryController.cs | 35 ++ .../Controllers/MembershipController.cs | 16 +- .../Controllers/StaffController.cs | 35 ++ .../src/WebClientTpos.Server/Program.cs | 1 + .../Commands/CreateProductCommand.cs | 6 + .../Commands/CreateProductCommandHandler.cs | 3 +- .../Commands/UpdateProductCommand.cs | 6 + .../Commands/UpdateProductCommandHandler.cs | 4 + .../Application/DTOs/ProductDto.cs | 12 + .../Queries/GetProductByIdQueryHandler.cs | 11 + .../Application/Queries/GetProductsQuery.cs | 6 + .../Queries/GetProductsQueryHandler.cs | 15 + .../Controllers/ProductsController.cs | 4 + .../ProductAggregate/Product.cs | 21 +- .../ProductEntityTypeConfiguration.cs | 5 + .../Commands/RecipeCommandHandlers.cs | 62 +++ .../Application/Commands/RecipeCommands.cs | 28 ++ .../Application/Queries/RecipeQueries.cs | 26 ++ .../Controllers/KitchenController.cs | 37 ++ .../RecipeAggregate/IRecipeRepository.cs | 12 + .../AggregatesModel/RecipeAggregate/Recipe.cs | 65 +++ .../RecipeAggregate/RecipeIngredient.cs | 30 ++ .../DependencyInjection.cs | 2 + .../RecipeEntityTypeConfiguration.cs | 36 ++ ...RecipeIngredientEntityTypeConfiguration.cs | 22 + .../FnbEngine.Infrastructure/FnbContext.cs | 5 + .../Repositories/RecipeRepository.cs | 26 ++ .../Commands/CreateMemberCommand.cs | 12 + .../Commands/CreateMemberCommandHandler.cs | 11 + .../Application/Queries/GetMemberByIdQuery.cs | 2 + .../Queries/GetMemberByIdQueryHandler.cs | 14 + .../Queries/GetMembersQueryHandler.cs | 39 +- .../Commands/CreateOrderCommand.cs | 5 +- .../Commands/CreateOrderCommandHandler.cs | 7 + .../AggregatesModel/OrderAggregate/Order.cs | 25 +- .../OrderEntityTypeConfiguration.cs | 15 + 40 files changed, 1166 insertions(+), 68 deletions(-) create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommandHandlers.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommands.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/Queries/RecipeQueries.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/IRecipeRepository.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/Recipe.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/RecipeIngredient.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeEntityTypeConfiguration.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index f6eb8742..63ae1c3a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -192,7 +192,7 @@ case "products":

@(_products.Count) sản phẩm

- @@ -213,6 +213,15 @@
+
+ +
@@ -313,35 +322,238 @@ // ═══ INVENTORY ═══ case "inventory": - @if (!_inventory.Any()) + @if (!_inventory.Any() && _invSubTab == "levels") { @RenderEmpty("warehouse", "#3B82F6", "Chưa có tồn kho", "Tồn kho sẽ hiển thị khi có sản phẩm", "package", "Thêm sản phẩm trước", $"/admin/shop/{ShopId}/menu") } else { +
@_inventory.Count(i => i.Quantity > 10)Còn hàng
@_inventory.Count(i => i.Quantity > 0 && i.Quantity <= 10)Sắp hết
@_inventory.Count(i => i.Quantity <= 0)Hết hàng
-
-
- - - - - - @foreach (var item in _inventory) - { - - - - - - } -
Sản phẩmSố lượngMức nhập lại
@(item.ProductName ?? item.ProductId.ToString()[..8])@item.Quantity@item.ReorderLevel
-
+ + +
+ @foreach (var (label, val, icon) in new[] { ("Tồn kho", "levels", "package"), ("Nhập kho", "stock-in", "arrow-down-to-line"), ("Xuất kho", "stock-out", "arrow-up-from-line"), ("Điều chỉnh", "adjust", "settings-2"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") }) + { + + }
+ + @if (_invFormMessage != null) + { +
+ @_invFormMessage +
+ } + + @switch (_invSubTab) + { + case "levels": +
+
+ + + + + + + @foreach (var item in _inventory) + { + var qtyColor = item.Quantity <= 0 ? "#EF4444" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "#F59E0B" : "#22C55E"; + var bgColor = item.Quantity <= 0 ? "rgba(239,68,68,0.05)" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "rgba(245,158,11,0.05)" : "transparent"; + + + + + + + } +
Sản phẩmSố lượngMức nhập lạiThao tác
@(item.ProductName ?? item.ProductId.ToString()[..8])@item.Quantity@item.ReorderLevel +
+ + +
+
+
+
+ break; + + case "stock-in": +
+

Nhập kho

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ break; + + case "stock-out": +
+

Xuất kho

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ break; + + case "adjust": +
+

Điều chỉnh tồn kho

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ break; + + case "transactions": +
+

Lịch sử giao dịch kho

+
+ @if (_invTxns.Any()) + { + + + + + + + @foreach (var tx in _invTxns.OrderByDescending(t => t.CreatedAt).Take(50)) + { + var txColor = tx.QuantityChange > 0 ? "#22C55E" : tx.QuantityChange < 0 ? "#EF4444" : "#6B7280"; + var txLabel = tx.TransactionType switch { "StockIn" => "Nhập kho", "StockOut" => "Xuất kho", "Adjustment" => "Điều chỉnh", "OrderDeduction" => "Đơn hàng", _ => tx.TransactionType ?? "N/A" }; + + + + + + + } +
Thời gianLoạiSố lượngLý do
@tx.CreatedAt.ToLocalTime().ToString("dd/MM HH:mm") + @txLabel + @(tx.QuantityChange > 0 ? "+" : "")@tx.QuantityChange@(tx.Reason ?? "—")
+ } + else + { +
Chưa có giao dịch kho nào.
+ } +
+
+ break; + + case "low-stock": +
+
+

Cảnh báo tồn kho thấp

+ +
+
+ @if (_lowStockItems.Any()) + { + + + + + + + @foreach (var item in _lowStockItems) + { + + + + + + + } +
Sản phẩmTồn khoNgưỡngHành động
@(item.ProductName ?? item.ProductId.ToString()[..8])@item.Quantity@item.LowStockThreshold + +
+ } + else + { +
+
+ Tất cả sản phẩm đều đủ hàng! +
+ } +
+
+ break; + } } break; @@ -463,7 +675,7 @@ case "staff":

@(_staff.Count) nhân viên

- @@ -485,8 +697,24 @@
-
+
+ @if (!_editingStaffId.HasValue) + { +
+ + @if (_createStaffAccount) + { +
+
+
+
+
+ } +
+ }
@@ -548,7 +776,7 @@
-
@@ -559,6 +787,8 @@

@(_editingMemberId.HasValue ? "Sửa khách hàng" : "Thêm khách hàng")

+
+
+ + @if (_appliedVoucher != null) + { + + } +
+ @if (_voucherMessage != null) + { +
@_voucherMessage
+ } + @if (_appliedVoucher != null) + { +
+ Giảm giá (@_appliedVoucher.CampaignName) + -@FormatPrice(_discountAmount) +
+ }
Tổng cộng - @FormatPrice(CartTotal) + @FormatPrice(FinalTotal)
Thanh toán - @FormatPrice(CartTotal) + @FormatPrice(FinalTotal)
💵 Tiền mặt - @FormatPrice(CartTotal) + @FormatPrice(FinalTotal)
Số tiền nhanh
@@ -168,7 +188,7 @@
-
@@ -185,7 +205,7 @@ @GetMethodLabel()
-
@FormatPrice(CartTotal)
+
@FormatPrice(FinalTotal)
@if (_selectedMethod == "qr") {
@@ -464,6 +484,13 @@ private IEnumerable FilteredProducts => _selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory); private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty); + private decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount); + + // Voucher state + private string _voucherCode = ""; + private string? _voucherMessage; + private PosDataService.VoucherValidationInfo? _appliedVoucher; + private decimal _discountAmount; protected override async Task OnInitializedAsync() { @@ -478,7 +505,7 @@ var apiProducts = await productsTask; var apiCategories = await categoriesTask; - _products = apiProducts.Select(p => new Product(p.Id, p.Name, p.Price, p.Category ?? "Khác")).ToList(); + _products = apiProducts.Select(p => new Product(p.Id, p.Name, p.Price, p.CategoryName ?? "Khác")).ToList(); var catNames = apiCategories.Select(c => c.Name).ToList(); if (catNames.Count > 0) @@ -507,6 +534,20 @@ if (item.Qty <= 0) _cartItems.Remove(item); } + // ═══════════════ VOUCHER ═══════════════ + private async Task ValidateVoucher() + { + _voucherMessage = null; _appliedVoucher = null; _discountAmount = 0; + if (string.IsNullOrWhiteSpace(_voucherCode)) { _voucherMessage = "Vui lòng nhập mã voucher."; return; } + var info = await DataService.ValidateVoucherAsync(_voucherCode.Trim()); + if (info == null) { _voucherMessage = "Không thể kiểm tra voucher."; return; } + if (!info.IsValid) { _voucherMessage = info.ErrorMessage ?? "Mã voucher không hợp lệ."; return; } + _appliedVoucher = info; + _discountAmount = Math.Min(info.RemainingValue ?? 0, CartTotal); + _voucherMessage = $"Voucher {info.CampaignName}: giảm {FormatPrice(_discountAmount)}"; + } + private void ClearVoucher() { _appliedVoucher = null; _discountAmount = 0; _voucherCode = ""; _voucherMessage = null; } + // ═══════════════ INLINE PAYMENT ═══════════════ private enum PayStep { None, MethodSelect, AmountInput, Processing, Success } private PayStep _paymentStep = PayStep.None; @@ -517,7 +558,7 @@ private string _lastTransactionId = ""; private string _lastPaymentMethod = ""; private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new(); - private decimal ChangeAmount => _receivedAmount - CartTotal; + private decimal ChangeAmount => _receivedAmount - FinalTotal; private void StartPayment() { @@ -556,7 +597,7 @@ private List<(string Label, decimal Value)> GetQuickAmounts() { - var total = CartTotal; + var total = FinalTotal; var amounts = new List<(string, decimal)>(); var roundUp = Math.Ceiling(total / 50_000) * 50_000; if (roundUp == total) roundUp += 50_000; @@ -583,7 +624,7 @@ _paymentProcessing = true; StateHasChanged(); - _lastOrderTotal = CartTotal; + _lastOrderTotal = FinalTotal; _lastPaymentMethod = _selectedMethod; _lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList(); var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; @@ -596,7 +637,10 @@ ShopId, _selectedMethod, _cartItems.Select(i => new PosDataService.PosOrderItemRequest( - i.ProductId, i.Name, i.Qty, i.Price)).ToList()); + i.ProductId, i.Name, i.Qty, i.Price)).ToList(), + _discountAmount > 0 ? _discountAmount : null, + _appliedVoucher != null ? "voucher" : null, + _appliedVoucher?.VoucherCode); var result = await DataService.CreatePosOrderAsync(orderReq); _lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; @@ -636,6 +680,7 @@ _selectedMethod = ""; _receivedAmount = 0; _customAmountInput = ""; + ClearVoucher(); } /// diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index cff73c6a..3f3b46c0 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -109,7 +109,7 @@ public class PosDataService } public record ShopInfo(Guid Id, string Name, string Slug, string? Description, string? Phone, string? Email, string? Category, string? Status, Guid? MerchantId = null); - public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? Category, int? DurationMinutes); + public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? CategoryName, int? DurationMinutes, Guid? CategoryId = null); public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder); public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt); public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName); @@ -141,11 +141,11 @@ public class PosDataService // EN: Admin-level records with shop_id and category info // VI: Record cấp admin với shop_id và thông tin danh mục public record AdminProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, - string? ImageUrl, bool IsActive, string? Type, Guid ShopId, DateTime CreatedAt, string? CategoryName); + string? ImageUrl, bool IsActive, string? Type, Guid ShopId, DateTime CreatedAt, string? CategoryName, Guid? CategoryId = null); public record AdminCategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder, Guid ShopId, Guid? ParentId, bool IsActive); public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, - string? Type, string? Sku, string? ImageUrl); + string? Type, string? Sku, string? ImageUrl, Guid? CategoryId = null); public async Task> GetAllProductsAsync(Guid? shopId = null) { @@ -194,10 +194,14 @@ public class PosDataService // ═══ MEMBERSHIP/CUSTOMER METHODS ═══ public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp, - int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName); + int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName, + string? DisplayName = null, string? Phone = null); - public async Task> GetMembersAsync() - => await GetListFromApiAsync("api/bff/members"); + public async Task> GetMembersAsync(string? search = null) + { + var url = string.IsNullOrWhiteSpace(search) ? "api/bff/members" : $"api/bff/members?search={Uri.EscapeDataString(search)}"; + return await GetListFromApiAsync(url); + } // ═══ STAFF CREATE ═══ @@ -323,7 +327,7 @@ public class PosDataService // EN: Member create/update request DTOs // VI: DTO tạo/cập nhật thành viên - public record CreateMemberRequest(string? Gender, string? CountryCode); + public record CreateMemberRequest(string? Gender, string? CountryCode, string? Name = null, string? Phone = null); public record UpdateMemberRequest(string? Gender, string? Preferences); public async Task CreateMemberAsync(CreateMemberRequest req) @@ -357,6 +361,43 @@ public class PosDataService return await GetListFromApiAsync(url); } + // ═══ INVENTORY OPERATIONS ═══ + + public record StockInRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes); + + public async Task StockInAsync(StockInRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-in", req, _writeOptions); + return resp.IsSuccessStatusCode; + } + + public record StockOutRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes); + + public async Task StockOutAsync(StockOutRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-out", req, _writeOptions); + return resp.IsSuccessStatusCode; + } + + public record AdjustStockRequest(Guid ProductId, Guid ShopId, int NewQuantity, string Notes); + + public async Task AdjustStockAsync(AdjustStockRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/inventory/adjust", req, _writeOptions); + return resp.IsSuccessStatusCode; + } + + public record LowStockItemInfo(Guid Id, Guid ProductId, string? ProductName, int Quantity, int LowStockThreshold, Guid ShopId); + + public async Task> GetLowStockAsync(Guid? shopId = null) + { + var url = shopId.HasValue ? $"api/bff/inventory/low-stock?shopId={shopId}" : "api/bff/inventory/low-stock"; + return await GetListFromApiAsync(url); + } + // ═══ MEMBERSHIP LEVELS ═══ public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount); @@ -417,7 +458,8 @@ public class PosDataService // EN: POS order creation DTOs // VI: DTOs cho tạo đơn POS - public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items); + public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items, + decimal? DiscountAmount = null, string? DiscountType = null, string? DiscountReference = null); public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice, string? ProductType = "Physical"); public record CreatePosOrderResponse(Guid OrderId, string TransactionId, decimal TotalAmount, string Status); @@ -623,4 +665,38 @@ public class PosDataService public async Task DeleteRecipeAsync(Guid recipeId) { AttachToken(); var r = await _http.DeleteAsync($"api/bff/recipes/{recipeId}"); return r.IsSuccessStatusCode; } + + // ═══ VOUCHER VALIDATION ═══ + + public record VoucherValidationInfo(bool IsValid, string? ErrorMessage, Guid? VoucherId, + string? VoucherCode, decimal? RemainingValue, DateTime? ExpiresAt, string? CampaignName); + + public async Task ValidateVoucherAsync(string code) + => await GetObjectFromApiAsync($"api/bff/vouchers/validate/{Uri.EscapeDataString(code)}"); + + public async Task RedeemVoucherAsync(Guid voucherId, decimal amount) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/vouchers/redeem", new { voucherId, amount }, _writeOptions); + return resp.IsSuccessStatusCode; + } + + // ═══ CAMPAIGN ACTIONS ═══ + + public async Task ActivateCampaignAsync(Guid campaignId) + { AttachToken(); var r = await _http.PostAsync($"api/bff/campaigns/{campaignId}/activate", null); return r.IsSuccessStatusCode; } + + public async Task PauseCampaignAsync(Guid campaignId) + { AttachToken(); var r = await _http.PostAsync($"api/bff/campaigns/{campaignId}/pause", null); return r.IsSuccessStatusCode; } + + // ═══ STAFF IAM ═══ + + public record InviteStaffWithAccountRequest(string Email, string Password, string FirstName, string LastName, string Role, Guid? ShopId); + + public async Task InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/staff/invite-with-account", req, _writeOptions); + return resp.IsSuccessStatusCode; + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs index 64f9dca8..8627f439 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs @@ -160,4 +160,42 @@ public class FinancialController : ControllerBase [HttpDelete("campaigns/{campaignId:guid}")] public Task DeleteCampaign(Guid campaignId) => _promotion.DeleteAsync($"/api/v1/campaigns/{campaignId}").ProxyAsync(); + + // ═══ VOUCHER ENDPOINTS ═══ + + /// + /// EN: Validate a voucher code. + /// VI: Kiểm tra mã voucher. + /// + [HttpGet("vouchers/validate/{code}")] + public async Task ValidateVoucher(string code) + { + var userId = GetUserIdFromToken(); + var qs = userId.HasValue ? $"?userId={userId}" : ""; + return await _promotion.GetAsync($"/api/v1/vouchers/validate/{code}{qs}").ProxyAsync(); + } + + /// + /// EN: Redeem a voucher. + /// VI: Sử dụng voucher. + /// + [HttpPost("vouchers/redeem")] + public Task RedeemVoucher([FromBody] JsonElement body) => + _promotion.PostAsJsonAsync("/api/v1/vouchers/redeem", body).ProxyAsync(); + + /// + /// EN: Activate a campaign. + /// VI: Kích hoạt chiến dịch. + /// + [HttpPost("campaigns/{campaignId:guid}/activate")] + public Task ActivateCampaign(Guid campaignId) => + _promotion.PostAsync($"/api/v1/campaigns/{campaignId}/activate", null).ProxyAsync(); + + /// + /// EN: Pause a campaign. + /// VI: Tạm dừng chiến dịch. + /// + [HttpPost("campaigns/{campaignId:guid}/pause")] + public Task PauseCampaign(Guid campaignId) => + _promotion.PostAsync($"/api/v1/campaigns/{campaignId}/pause", null).ProxyAsync(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs index 67bc8017..47b6b64a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs @@ -48,4 +48,39 @@ public class InventoryController : ControllerBase var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; return _inventory.GetAsync($"/api/v1/inventory/transactions{qs}").ProxyAsync(); } + + /// + /// EN: Stock in — add quantity to inventory. + /// VI: Nhập kho — thêm số lượng vào tồn kho. + /// + [HttpPost("inventory/stock-in")] + public Task StockIn([FromBody] JsonElement body) => + _inventory.PostAsJsonAsync("/api/v1/inventory/stock-in", body).ProxyAsync(); + + /// + /// EN: Stock out — remove quantity from inventory. + /// VI: Xuất kho — trừ số lượng khỏi tồn kho. + /// + [HttpPost("inventory/stock-out")] + public Task StockOut([FromBody] JsonElement body) => + _inventory.PostAsJsonAsync("/api/v1/inventory/stock-out", body).ProxyAsync(); + + /// + /// EN: Adjust stock — set exact quantity. + /// VI: Điều chỉnh kho — đặt số lượng chính xác. + /// + [HttpPost("inventory/adjust")] + public Task AdjustStock([FromBody] JsonElement body) => + _inventory.PostAsJsonAsync("/api/v1/inventory/adjust", body).ProxyAsync(); + + /// + /// EN: Get low stock alerts. + /// VI: Lấy cảnh báo hàng tồn kho thấp. + /// + [HttpGet("inventory/low-stock")] + public Task GetLowStock([FromQuery] Guid? shopId = null) + { + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _inventory.GetAsync($"/api/v1/inventory/low-stock{qs}").ProxyAsync(); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs index ef395fe3..0a5ec463 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs @@ -24,8 +24,20 @@ public class MembershipController : ControllerBase /// VI: Lấy danh sách thành viên (khách hàng). /// [HttpGet("members")] - public Task GetMembers() => - _membership.GetAsync("/api/v1/members").ProxyAsync(); + public Task GetMembers([FromQuery] string? search = null, [FromQuery] int pageSize = 100) + { + var qs = $"?pageSize={pageSize}"; + if (!string.IsNullOrWhiteSpace(search)) qs += $"&search={Uri.EscapeDataString(search)}"; + return _membership.GetAsync($"/api/v1/members{qs}").ProxyAsync(); + } + + /// + /// EN: Get a member by ID. + /// VI: Lấy thành viên theo ID. + /// + [HttpGet("members/{memberId:guid}")] + public Task GetMemberById(Guid memberId) => + _membership.GetAsync($"/api/v1/members/{memberId}").ProxyAsync(); /// /// EN: Create a member. diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs index e60d0b4e..df1d9f01 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs @@ -14,11 +14,13 @@ public class StaffController : ControllerBase { private readonly HttpClient _merchant; private readonly HttpClient _booking; + private readonly HttpClient _iam; public StaffController(IHttpClientFactory httpClientFactory) { _merchant = httpClientFactory.CreateClient("MerchantService"); _booking = httpClientFactory.CreateClient("BookingService"); + _iam = httpClientFactory.CreateClient("IamService"); } /// @@ -37,6 +39,39 @@ public class StaffController : ControllerBase public Task CreateStaff([FromBody] JsonElement body) => _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff", body).ProxyAsync(); + /// + /// EN: Create IAM account + invite staff in one call. + /// VI: Tạo tài khoản IAM + mời nhân viên trong một lần gọi. + /// + [HttpPost("staff/invite-with-account")] + public async Task InviteStaffWithAccount([FromBody] JsonElement body) + { + // Step 1: Create IAM account + var iamPayload = new + { + email = body.TryGetProperty("email", out var e) ? e.GetString() : "", + password = body.TryGetProperty("password", out var pw) ? pw.GetString() : "", + firstName = body.TryGetProperty("firstName", out var fn) ? fn.GetString() : "", + lastName = body.TryGetProperty("lastName", out var ln) ? ln.GetString() : "" + }; + var iamResponse = await _iam.PostAsJsonAsync("/api/v1/auth/register", iamPayload); + if (!iamResponse.IsSuccessStatusCode) + { + var err = await iamResponse.Content.ReadAsStringAsync(); + return StatusCode((int)iamResponse.StatusCode, + new { success = false, message = "IAM account creation failed", details = err }); + } + + // Step 2: Invite staff via MerchantService + var invitePayload = new + { + email = iamPayload.email, + role = body.TryGetProperty("role", out var r) ? r.GetString() : "Cashier", + shopId = body.TryGetProperty("shopId", out var s) ? s.GetString() : null as string + }; + return await _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/invite", invitePayload).ProxyAsync(); + } + /// /// EN: Update a staff member. /// VI: Cập nhật nhân viên. diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs index 623774e9..a69298a6 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs @@ -116,6 +116,7 @@ AddServiceClient("WalletService", "WalletService__BaseUrl", "http://loca AddServiceClient("PromotionService", "PromotionService__BaseUrl", "http://localhost:5008"); AddServiceClient("BookingService", "BookingService__BaseUrl", "http://localhost:5020"); AddServiceClient("FnbEngine", "FnbEngine__BaseUrl", "http://localhost:5019"); +AddServiceClient("IamService", "IamService__BaseUrl", "http://localhost:5001"); var app = builder.Build(); diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs index d55dad7c..14858ee5 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs @@ -58,4 +58,10 @@ public record CreateProductCommand : IRequest /// VI: URL hình ảnh sản phẩm. /// public string? ImageUrl { get; init; } + + /// + /// EN: Category ID for product classification. + /// VI: ID danh mục để phân loại sản phẩm. + /// + public Guid? CategoryId { get; init; } } diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs index 9aa06677..e8757e01 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs @@ -45,7 +45,8 @@ public class CreateProductCommandHandler : IRequestHandler /// VI: URL hình ảnh sản phẩm. /// public string? ImageUrl { get; init; } + + /// + /// EN: Category ID for product classification. + /// VI: ID danh mục để phân loại sản phẩm. + /// + public Guid? CategoryId { get; init; } } diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs index 58cf8458..8b2988e2 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs @@ -36,6 +36,10 @@ public class UpdateProductCommandHandler : IRequestHandler public string? Sku { get; init; } + /// + /// EN: Category ID. + /// VI: ID danh mục. + /// + public Guid? CategoryId { get; init; } + + /// + /// EN: Category name (resolved from lookup). + /// VI: Tên danh mục (resolve từ lookup). + /// + public string? CategoryName { get; init; } + /// /// EN: Is product active. /// VI: Sản phẩm có đang hoạt động không. diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs index 192fa07f..724f0b2a 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs @@ -33,6 +33,15 @@ public class GetProductByIdQueryHandler : IRequestHandler c.Id == entity.CategoryId.Value, cancellationToken); + categoryName = cat?.Name; + } + return new ProductDto { Id = entity.Id, @@ -46,6 +55,8 @@ public class GetProductByIdQueryHandler : IRequestHandler> /// public string? Type { get; init; } + /// + /// EN: Filter by category ID (null = all). + /// VI: Lọc theo danh mục (null = tất cả). + /// + public Guid? CategoryId { get; init; } + /// /// EN: Page number (1-indexed). /// VI: Số trang (bắt đầu từ 1). diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs index 2b0d06dd..70f099e8 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs @@ -44,6 +44,11 @@ public class GetProductsQueryHandler : IRequestHandler p.TypeId == typeEnum.Id); } + if (request.CategoryId.HasValue) + { + query = query.Where(p => p.CategoryId == request.CategoryId.Value); + } + // EN: Get total count // VI: Lấy tổng số var totalCount = await query.CountAsync(cancellationToken); @@ -62,6 +67,14 @@ public class GetProductsQueryHandler : IRequestHandler().ToDictionary(t => t.Id, t => t.Name); + // EN: Build CategoryId → Name lookup. + // VI: Build lookup CategoryId → Name. + var categoryIds = entities.Where(p => p.CategoryId.HasValue).Select(p => p.CategoryId!.Value).Distinct().ToList(); + var categoryMap = categoryIds.Count > 0 + ? await _context.Categories.Where(c => categoryIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken) + : new Dictionary(); + var products = entities.Select(p => new ProductDto { Id = p.Id, @@ -75,6 +88,8 @@ public class GetProductsQueryHandler : IRequestHandler public string? Sku => _sku; + /// + /// EN: Category ID for product classification. + /// VI: ID danh mục để phân loại sản phẩm. + /// + public Guid? CategoryId => _categoryId; + /// /// EN: Is product active and available for sale. /// VI: Sản phẩm có đang hoạt động và sẵn sàng bán không. @@ -110,7 +117,8 @@ public class Product : Entity, IAggregateRoot ProductType type, string? description = null, JsonDocument? attributes = null, - string? sku = null) + string? sku = null, + Guid? categoryId = null) { if (shopId == Guid.Empty) throw new DomainException("Shop ID cannot be empty"); @@ -128,6 +136,7 @@ public class Product : Entity, IAggregateRoot TypeId = type.Id; _attributes = attributes; _sku = sku?.Trim(); + _categoryId = categoryId; _isActive = true; _createdAt = DateTime.UtcNow; @@ -184,6 +193,16 @@ public class Product : Entity, IAggregateRoot _updatedAt = DateTime.UtcNow; } + /// + /// EN: Set category for product. + /// VI: Đặt danh mục cho sản phẩm. + /// + public void SetCategory(Guid? categoryId) + { + _categoryId = categoryId; + _updatedAt = DateTime.UtcNow; + } + /// /// EN: Deactivate product. /// VI: Vô hiệu hóa sản phẩm. diff --git a/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs b/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs index 1c497819..4256e7a6 100644 --- a/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs +++ b/services/catalog-service-net/src/CatalogService.Infrastructure/EntityConfigurations/ProductEntityTypeConfiguration.cs @@ -77,6 +77,10 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration .HasColumnName("sku") .HasMaxLength(100); + builder.Property(p => p.CategoryId) + .HasField("_categoryId") + .HasColumnName("category_id"); + builder.Property(p => p.IsActive) .HasField("_isActive") .HasColumnName("is_active") @@ -97,6 +101,7 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration builder.HasIndex(p => p.TypeId).HasDatabaseName("ix_products_type_id"); builder.HasIndex(p => p.Sku).HasDatabaseName("ix_products_sku"); builder.HasIndex(p => p.IsActive).HasDatabaseName("ix_products_is_active"); + builder.HasIndex(p => p.CategoryId).HasDatabaseName("ix_products_category_id"); // EN: TypeId is a plain FK column — no navigation property to ProductType. // Type name is resolved from Enumeration in query handlers. diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommandHandlers.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommandHandlers.cs new file mode 100644 index 00000000..e0c93b34 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommandHandlers.cs @@ -0,0 +1,62 @@ +using MediatR; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; + +namespace FnbEngine.API.Application.Commands; + +public class CreateRecipeCommandHandler : IRequestHandler +{ + private readonly IRecipeRepository _repo; + public CreateRecipeCommandHandler(IRecipeRepository repo) => _repo = repo; + + public async Task Handle(CreateRecipeCommand req, CancellationToken ct) + { + var recipe = new Recipe(req.ShopId, req.ProductId, req.Name, req.Instructions, req.PrepTimeMinutes); + if (req.Ingredients != null) + { + foreach (var ing in req.Ingredients) + recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit); + } + _repo.Add(recipe); + await _repo.UnitOfWork.SaveEntitiesAsync(ct); + return recipe.Id; + } +} + +public class UpdateRecipeCommandHandler : IRequestHandler +{ + private readonly IRecipeRepository _repo; + public UpdateRecipeCommandHandler(IRecipeRepository repo) => _repo = repo; + + public async Task Handle(UpdateRecipeCommand req, CancellationToken ct) + { + var recipe = await _repo.GetByIdAsync(req.RecipeId, ct); + if (recipe == null) return false; + + recipe.Update(req.Name, req.Instructions, req.PrepTimeMinutes); + recipe.ClearIngredients(); + if (req.Ingredients != null) + { + foreach (var ing in req.Ingredients) + recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit); + } + _repo.Update(recipe); + await _repo.UnitOfWork.SaveEntitiesAsync(ct); + return true; + } +} + +public class DeleteRecipeCommandHandler : IRequestHandler +{ + private readonly IRecipeRepository _repo; + public DeleteRecipeCommandHandler(IRecipeRepository repo) => _repo = repo; + + public async Task Handle(DeleteRecipeCommand req, CancellationToken ct) + { + var recipe = await _repo.GetByIdAsync(req.RecipeId, ct); + if (recipe == null) return false; + recipe.Deactivate(); + _repo.Update(recipe); + await _repo.UnitOfWork.SaveEntitiesAsync(ct); + return true; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommands.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommands.cs new file mode 100644 index 00000000..6efadf91 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommands.cs @@ -0,0 +1,28 @@ +using MediatR; + +namespace FnbEngine.API.Application.Commands; + +public record CreateRecipeCommand : IRequest +{ + public Guid ShopId { get; init; } + public Guid ProductId { get; init; } + public string Name { get; init; } = ""; + public string? Instructions { get; init; } + public int PrepTimeMinutes { get; init; } + public List? Ingredients { get; init; } +} + +public record UpdateRecipeCommand : IRequest +{ + public Guid RecipeId { get; init; } + public Guid ShopId { get; init; } + public Guid ProductId { get; init; } + public string Name { get; init; } = ""; + public string? Instructions { get; init; } + public int PrepTimeMinutes { get; init; } + public List? Ingredients { get; init; } +} + +public record DeleteRecipeCommand(Guid RecipeId) : IRequest; + +public record IngredientItem(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/RecipeQueries.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/RecipeQueries.cs new file mode 100644 index 00000000..3f4dc188 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/RecipeQueries.cs @@ -0,0 +1,26 @@ +using MediatR; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; + +namespace FnbEngine.API.Application.Queries; + +public record GetRecipesByShopQuery(Guid ShopId) : IRequest>; + +public record RecipeDto(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions, + int PrepTimeMinutes, bool IsActive, DateTime CreatedAt, List Ingredients); + +public record RecipeIngredientDto(Guid Id, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); + +public class GetRecipesByShopQueryHandler : IRequestHandler> +{ + private readonly IRecipeRepository _repo; + public GetRecipesByShopQueryHandler(IRecipeRepository repo) => _repo = repo; + + public async Task> Handle(GetRecipesByShopQuery req, CancellationToken ct) + { + var recipes = await _repo.GetByShopIdAsync(req.ShopId, ct); + return recipes.Select(r => new RecipeDto( + r.Id, r.ProductId, r.ShopId, r.Name, r.Instructions, r.PrepTimeMinutes, r.IsActive, r.CreatedAt, + r.Ingredients.Select(i => new RecipeIngredientDto(i.Id, i.IngredientName, i.Quantity, i.Unit, i.CostPerUnit)).ToList() + )); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs b/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs index 9ee09bb9..b82b1aec 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs @@ -62,6 +62,43 @@ public class KitchenController : ControllerBase var result = await _mediator.Send(new UpdateTicketStatusCommand(id, request.Status), ct); return Ok(new ApiResponse { Success = true, Data = result }); } + // ═══ RECIPE ENDPOINTS ═══ + + [HttpGet("recipes")] + [ProducesResponseType(typeof(ApiResponse>), 200)] + public async Task>>> GetRecipes( + [FromQuery] Guid shopId, CancellationToken ct = default) + { + var result = await _mediator.Send(new GetRecipesByShopQuery(shopId), ct); + return Ok(new ApiResponse> { Success = true, Data = result }); + } + + [HttpPost("recipes")] + [ProducesResponseType(typeof(ApiResponse), 200)] + public async Task>> CreateRecipe( + [FromBody] CreateRecipeCommand command, CancellationToken ct = default) + { + var id = await _mediator.Send(command, ct); + return Ok(new ApiResponse { Success = true, Data = id }); + } + + [HttpPut("recipes/{id}")] + [ProducesResponseType(typeof(ApiResponse), 200)] + public async Task>> UpdateRecipe( + Guid id, [FromBody] UpdateRecipeCommand command, CancellationToken ct = default) + { + var cmd = command with { RecipeId = id }; + var result = await _mediator.Send(cmd, ct); + return Ok(new ApiResponse { Success = true, Data = result }); + } + + [HttpDelete("recipes/{id}")] + [ProducesResponseType(typeof(ApiResponse), 200)] + public async Task>> DeleteRecipe(Guid id, CancellationToken ct = default) + { + var result = await _mediator.Send(new DeleteRecipeCommand(id), ct); + return Ok(new ApiResponse { Success = true, Data = result }); + } } public record UpdateStatusRequest(string Status); diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/IRecipeRepository.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/IRecipeRepository.cs new file mode 100644 index 00000000..a37c49bb --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/IRecipeRepository.cs @@ -0,0 +1,12 @@ +using FnbEngine.Domain.SeedWork; + +namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate; + +public interface IRecipeRepository : IRepository +{ + Recipe Add(Recipe recipe); + void Update(Recipe recipe); + void Delete(Recipe recipe); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetByShopIdAsync(Guid shopId, CancellationToken ct = default); +} diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/Recipe.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/Recipe.cs new file mode 100644 index 00000000..cb0da74e --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/Recipe.cs @@ -0,0 +1,65 @@ +using FnbEngine.Domain.SeedWork; + +namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate; + +public class Recipe : Entity, IAggregateRoot +{ + private Guid _shopId; + private Guid _productId; + private string _name = null!; + private string? _instructions; + private int _prepTimeMinutes; + private bool _isActive; + private DateTime _createdAt; + private DateTime? _updatedAt; + private readonly List _ingredients = new(); + + public Guid ShopId => _shopId; + public Guid ProductId => _productId; + public string Name => _name; + public string? Instructions => _instructions; + public int PrepTimeMinutes => _prepTimeMinutes; + public bool IsActive => _isActive; + public DateTime CreatedAt => _createdAt; + public DateTime? UpdatedAt => _updatedAt; + public IReadOnlyList Ingredients => _ingredients.AsReadOnly(); + + protected Recipe() { } + + public Recipe(Guid shopId, Guid productId, string name, string? instructions, int prepTimeMinutes) + { + Id = Guid.NewGuid(); + _shopId = shopId; + _productId = productId; + _name = name; + _instructions = instructions; + _prepTimeMinutes = prepTimeMinutes; + _isActive = true; + _createdAt = DateTime.UtcNow; + } + + public void Update(string name, string? instructions, int prepTimeMinutes) + { + _name = name; + _instructions = instructions; + _prepTimeMinutes = prepTimeMinutes; + _updatedAt = DateTime.UtcNow; + } + + public void AddIngredient(string ingredientName, decimal quantity, string unit, decimal costPerUnit) + { + _ingredients.Add(new RecipeIngredient(Id, ingredientName, quantity, unit, costPerUnit)); + } + + public void ClearIngredients() + { + _ingredients.Clear(); + _updatedAt = DateTime.UtcNow; + } + + public void Deactivate() + { + _isActive = false; + _updatedAt = DateTime.UtcNow; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/RecipeIngredient.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/RecipeIngredient.cs new file mode 100644 index 00000000..ec54ae49 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/RecipeIngredient.cs @@ -0,0 +1,30 @@ +using FnbEngine.Domain.SeedWork; + +namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate; + +public class RecipeIngredient : Entity +{ + private Guid _recipeId; + private string _ingredientName = null!; + private decimal _quantity; + private string _unit = null!; + private decimal _costPerUnit; + + public Guid RecipeId => _recipeId; + public string IngredientName => _ingredientName; + public decimal Quantity => _quantity; + public string Unit => _unit; + public decimal CostPerUnit => _costPerUnit; + + protected RecipeIngredient() { } + + public RecipeIngredient(Guid recipeId, string ingredientName, decimal quantity, string unit, decimal costPerUnit) + { + Id = Guid.NewGuid(); + _recipeId = recipeId; + _ingredientName = ingredientName; + _quantity = quantity; + _unit = unit; + _costPerUnit = costPerUnit; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs index 4b939ff9..7a20cf6a 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using FnbEngine.Domain.AggregatesModel.TableAggregate; using FnbEngine.Domain.AggregatesModel.SessionAggregate; using FnbEngine.Domain.AggregatesModel.KitchenAggregate; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; using FnbEngine.Infrastructure.Idempotency; using FnbEngine.Infrastructure.Repositories; @@ -52,6 +53,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeEntityTypeConfiguration.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeEntityTypeConfiguration.cs new file mode 100644 index 00000000..6cb356ff --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeEntityTypeConfiguration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; + +namespace FnbEngine.Infrastructure.EntityConfigurations; + +public class RecipeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("recipes"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasColumnName("id").ValueGeneratedNever(); + builder.Property(r => r.ShopId).HasField("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property(r => r.ProductId).HasField("_productId").HasColumnName("product_id").IsRequired(); + builder.Property(r => r.Name).HasField("_name").HasColumnName("name").HasMaxLength(255).IsRequired(); + builder.Property(r => r.Instructions).HasField("_instructions").HasColumnName("instructions").HasMaxLength(2000); + builder.Property(r => r.PrepTimeMinutes).HasField("_prepTimeMinutes").HasColumnName("prep_time_minutes"); + builder.Property(r => r.IsActive).HasField("_isActive").HasColumnName("is_active").HasDefaultValue(true); + builder.Property(r => r.CreatedAt).HasField("_createdAt").HasColumnName("created_at").IsRequired(); + builder.Property(r => r.UpdatedAt).HasField("_updatedAt").HasColumnName("updated_at"); + + builder.HasMany(r => r.Ingredients) + .WithOne() + .HasForeignKey(i => i.RecipeId) + .OnDelete(DeleteBehavior.Cascade); + + var nav = builder.Metadata.FindNavigation(nameof(Recipe.Ingredients))!; + nav.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasIndex(r => r.ShopId).HasDatabaseName("ix_recipes_shop_id"); + builder.HasIndex(r => r.ProductId).HasDatabaseName("ix_recipes_product_id"); + + builder.Ignore(r => r.DomainEvents); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs new file mode 100644 index 00000000..279ec2ff --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; + +namespace FnbEngine.Infrastructure.EntityConfigurations; + +public class RecipeIngredientEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("recipe_ingredients"); + builder.HasKey(ri => ri.Id); + builder.Property(ri => ri.Id).HasColumnName("id").ValueGeneratedNever(); + builder.Property(ri => ri.RecipeId).HasField("_recipeId").HasColumnName("recipe_id").IsRequired(); + builder.Property(ri => ri.IngredientName).HasField("_ingredientName").HasColumnName("ingredient_name").HasMaxLength(200).IsRequired(); + builder.Property(ri => ri.Quantity).HasField("_quantity").HasColumnName("quantity").HasColumnType("decimal(18,4)").IsRequired(); + builder.Property(ri => ri.Unit).HasField("_unit").HasColumnName("unit").HasMaxLength(50).IsRequired(); + builder.Property(ri => ri.CostPerUnit).HasField("_costPerUnit").HasColumnName("cost_per_unit").HasColumnType("decimal(18,2)"); + + builder.Ignore(ri => ri.DomainEvents); + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs index 69330ef7..90e3efda 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbContext.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Storage; using FnbEngine.Domain.AggregatesModel.TableAggregate; using FnbEngine.Domain.AggregatesModel.SessionAggregate; using FnbEngine.Domain.AggregatesModel.KitchenAggregate; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; using FnbEngine.Domain.SeedWork; using FnbEngine.Infrastructure.EntityConfigurations; @@ -17,6 +18,8 @@ public class FnbContext : DbContext, IUnitOfWork public DbSet Tables => Set
(); public DbSet Sessions => Set(); public DbSet KitchenTickets => Set(); + public DbSet Recipes => Set(); + public DbSet RecipeIngredients => Set(); public IDbContextTransaction? CurrentTransaction => _currentTransaction; public bool HasActiveTransaction => _currentTransaction != null; @@ -31,6 +34,8 @@ public class FnbContext : DbContext, IUnitOfWork modelBuilder.ApplyConfiguration(new TableStatusEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new SessionEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new KitchenTicketEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RecipeEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RecipeIngredientEntityTypeConfiguration()); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs new file mode 100644 index 00000000..81ffe988 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using FnbEngine.Domain.AggregatesModel.RecipeAggregate; +using FnbEngine.Domain.SeedWork; + +namespace FnbEngine.Infrastructure.Repositories; + +public class RecipeRepository : IRecipeRepository +{ + private readonly FnbContext _context; + public IUnitOfWork UnitOfWork => _context; + + public RecipeRepository(FnbContext context) => _context = context; + + public Recipe Add(Recipe recipe) => _context.Recipes.Add(recipe).Entity; + public void Update(Recipe recipe) => _context.Recipes.Update(recipe); + public void Delete(Recipe recipe) => _context.Recipes.Remove(recipe); + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => + await _context.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == id, ct); + + public async Task> GetByShopIdAsync(Guid shopId, CancellationToken ct = default) => + await _context.Recipes.Include(r => r.Ingredients) + .Where(r => r.ShopId == shopId && r.IsActive) + .OrderBy(r => r.Name) + .ToListAsync(ct); +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs index 6c2ad83d..70cc8526 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs @@ -25,6 +25,18 @@ public class CreateMemberCommand : IRequest /// VI: Giới tính (tùy chọn). /// public string? Gender { get; set; } + + /// + /// EN: Display name (optional, stored in preferences). + /// VI: Tên hiển thị (tùy chọn, lưu trong preferences). + /// + public string? Name { get; set; } + + /// + /// EN: Phone number (optional, stored in preferences). + /// VI: Số điện thoại (tùy chọn, lưu trong preferences). + /// + public string? Phone { get; set; } } /// diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs index 70bf6389..7bcc6717 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using MediatR; using MembershipService.Domain.AggregatesModel.MemberAggregate; @@ -34,6 +35,16 @@ public class CreateMemberCommandHandler : IRequestHandler(); + if (!string.IsNullOrWhiteSpace(request.Name)) prefs["name"] = request.Name; + if (!string.IsNullOrWhiteSpace(request.Phone)) prefs["phone"] = request.Phone; + member.UpdatePreferences(JsonSerializer.Serialize(prefs)); + } + _memberRepository.Add(member); await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs index 726bd549..f9e03054 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs @@ -31,6 +31,8 @@ public class MemberDto public string? Gender { get; set; } public string CountryCode { get; set; } = null!; public string? Preferences { get; set; } + public string? DisplayName { get; set; } + public string? Phone { get; set; } /// /// EN: Current member level (1, 2, 3...). diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs index 11aaee0b..437fe50a 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using MediatR; using MembershipService.Domain.AggregatesModel.MemberAggregate; @@ -29,6 +30,17 @@ public class GetMemberByIdQueryHandler : IRequestHandler(member.Preferences); + if (prefs.TryGetProperty("name", out var n)) displayName = n.GetString(); + if (prefs.TryGetProperty("phone", out var p)) phone = p.GetString(); + } + catch { } + } return new MemberDto { Id = member.Id, @@ -36,6 +48,8 @@ public class GetMemberByIdQueryHandler : IRequestHandler new MemberDto + var memberDtos = members.Select(m => { - Id = m.Id, - UserId = m.UserId, - Gender = m.Gender, - CountryCode = m.CountryCode, - Preferences = m.Preferences, - CurrentLevel = m.CurrentLevel, - CurrentExp = m.CurrentExp, - TotalExpEarned = m.TotalExpEarned, - CreatedAt = m.CreatedAt, - UpdatedAt = m.UpdatedAt + string? displayName = null, phone = null; + if (!string.IsNullOrWhiteSpace(m.Preferences)) + { + try + { + var prefs = JsonSerializer.Deserialize(m.Preferences); + if (prefs.TryGetProperty("name", out var n)) displayName = n.GetString(); + if (prefs.TryGetProperty("phone", out var p)) phone = p.GetString(); + } + catch { } + } + return new MemberDto + { + Id = m.Id, + UserId = m.UserId, + Gender = m.Gender, + CountryCode = m.CountryCode, + Preferences = m.Preferences, + DisplayName = displayName, + Phone = phone, + CurrentLevel = m.CurrentLevel, + CurrentExp = m.CurrentExp, + TotalExpEarned = m.TotalExpEarned, + CreatedAt = m.CreatedAt, + UpdatedAt = m.UpdatedAt + }; }); return new GetMembersResult diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs index a0077ba4..013fa493 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs @@ -12,7 +12,10 @@ namespace OrderService.API.Application.Commands; public record CreateOrderCommand( Guid ShopId, Guid? CustomerId, - List Items + List Items, + decimal? DiscountAmount = null, + string? DiscountType = null, + string? DiscountReference = null ) : IRequest; /// diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs index 0013893b..7c563759 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs @@ -67,6 +67,13 @@ public class CreateOrderCommandHandler : IRequestHandler 0) + { + order.ApplyDiscount(request.DiscountAmount.Value, request.DiscountType, request.DiscountReference); + } + // EN: Mark order as validated after all items pass validation // VI: Đánh dấu order là validated sau khi tất cả items pass validation order.MarkAsValidated(); diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs index 662eb5f5..c51769aa 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs @@ -20,6 +20,10 @@ public class Order : Entity, IAggregateRoot private DateTime _createdAt; private DateTime? _updatedAt; + private decimal _discountAmount; + private string? _discountType; + private string? _discountReference; + private readonly List _items = new(); /// @@ -56,6 +60,10 @@ public class Order : Entity, IAggregateRoot /// EN: Order items (line items). /// VI: Các items trong đơn hàng (dòng hàng). /// + public decimal DiscountAmount => _discountAmount; + public string? DiscountType => _discountType; + public string? DiscountReference => _discountReference; + public IReadOnlyCollection Items => _items.AsReadOnly(); /// @@ -192,8 +200,23 @@ public class Order : Entity, IAggregateRoot AddDomainEvent(new OrderCancelledDomainEvent(this, reason)); } + /// + /// EN: Apply discount to order. + /// VI: Áp dụng giảm giá cho đơn hàng. + /// + public void ApplyDiscount(decimal amount, string? type = null, string? reference = null) + { + if (amount < 0) throw new DomainException("Discount amount cannot be negative"); + _discountAmount = amount; + _discountType = type; + _discountReference = reference; + RecalculateTotal(); + _updatedAt = DateTime.UtcNow; + } + private void RecalculateTotal() { - _totalAmount = _items.Sum(i => i.TotalPrice); + _totalAmount = _items.Sum(i => i.TotalPrice) - _discountAmount; + if (_totalAmount < 0) _totalAmount = 0; } } diff --git a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs index 09fd04d2..84d62dd7 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs @@ -50,6 +50,18 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration builder.Property("_updatedAt") .HasColumnName("updated_at"); + builder.Property("_discountAmount") + .HasColumnName("discount_amount") + .HasColumnType("decimal(18,2)"); + + builder.Property("_discountType") + .HasColumnName("discount_type") + .HasMaxLength(50); + + builder.Property("_discountReference") + .HasColumnName("discount_reference") + .HasMaxLength(255); + // EN: OrderItems collection // VI: Collection OrderItems builder.OwnsMany(o => o.Items, orderItems => @@ -122,6 +134,9 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration builder.Ignore(o => o.CustomerId); builder.Ignore(o => o.Status); builder.Ignore(o => o.TotalAmount); + builder.Ignore(o => o.DiscountAmount); + builder.Ignore(o => o.DiscountType); + builder.Ignore(o => o.DiscountReference); builder.Ignore(o => o.CreatedAt); builder.Ignore(o => o.UpdatedAt); }