diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopInventory.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopInventory.razor index d845bad9..05bbc2a0 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopInventory.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopInventory.razor @@ -22,9 +22,78 @@
@_inventory.Count(i => i.Quantity <= 0)Hết hàng
+ +
+ +
+ + @if (_showCreateForm) + { + +
+
+

Thêm nguyên liệu / vật tư

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ } + -
- @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") }) +
+ @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"), ("Hao hụt", "wastage", "trash-2"), ("Kiểm kê", "stocktake", "clipboard-check"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") }) { +
+
+ + @if (_stocktakeResult != null) + { +
+

Kết quả kiểm kê (@_stocktakeResult.TotalItemsCounted mặt hàng)

+
+ @if (_stocktakeResult.Discrepancies.Any()) + { + + + + + + + @foreach (var d in _stocktakeResult.Discrepancies) + { + var diffColor = d.Difference > 0 ? "#22C55E" : "#EF4444"; + + + + + + + } +
Nguyên liệuHệ thốngThực tếChênh lệch
@(d.ItemName ?? d.InventoryItemId.ToString()[..8])@d.ExpectedQuantity@d.CountedQuantity@(d.Difference > 0 ? "+" : "")@d.Difference
+ } + else + { +
+
+ Không có chênh lệch. Tồn kho chính xác! +
+ } +
+
+ } + break; + case "transactions":

Lịch sử giao dịch kho

@@ -177,7 +390,7 @@ @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" }; + var txLabel = tx.TransactionType switch { "StockIn" => "Nhập kho", "StockOut" => "Xuất kho", "Adjustment" => "Điều chỉnh", "OrderDeduction" => "Đơn hàng", "Wastage" => "Hao hụt", _ => tx.TransactionType ?? "N/A" }; @tx.CreatedAt.ToLocalTime().ToString("dd/MM HH:mm") @@ -260,6 +473,31 @@ private bool _invFormSuccess; private List _lowStockItems = new(); + // EN: Create inventory item form state / VI: State form tạo nguyên liệu + private bool _showCreateForm; + private string _createName = ""; + private int _createItemTypeId = 1; + private string _createUnit = "g"; + private decimal _createCostPerUnit; + private int _createInitialQty; + private int _createReorderLevel = 10; + private string _createSupplier = ""; + private DateTime? _createExpiryDate; + + // EN: Stock-in extra fields / VI: Trường bổ sung cho nhập kho + private decimal? _invStockInUnitCost; + private string _invStockInSupplier = ""; + + // EN: Wastage form state / VI: State form hao hụt + private Guid _wastageSelectedItemId; + private int _wastageAmount; + private string _wastageReason = ""; + private string _wastageNotes = ""; + + // EN: Stocktake state / VI: State kiểm kê + private Dictionary _stocktakeCounts = new(); + private PosDataService.StocktakeResult? _stocktakeResult; + protected override async Task OnInitializedAsync() { _inventory = await DataService.GetInventoryAsync(_shopGuid); @@ -284,10 +522,14 @@ { _invFormMessage = null; if (_invSelectedProductId == Guid.Empty || _invAmount <= 0) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập số lượng > 0."; _invFormSuccess = false; return; } - var ok = await DataService.StockInAsync(new PosDataService.StockInRequest(_invSelectedProductId, ShopId, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes)); + var supplier = string.IsNullOrWhiteSpace(_invStockInSupplier) ? null : _invStockInSupplier; + var ok = await DataService.StockInAsync(new PosDataService.StockInRequest( + _invSelectedProductId, ShopId, _invAmount, + string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes, + null, _invStockInUnitCost, supplier)); _invFormMessage = ok ? $"Đã nhập kho thành công +{_invAmount}!" : "Lỗi khi nhập kho."; _invFormSuccess = ok; - if (ok) { _invAmount = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); } + if (ok) { _invAmount = 0; _invNotes = ""; _invStockInUnitCost = null; _invStockInSupplier = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); } } private async Task DoStockOut() @@ -309,4 +551,97 @@ _invFormSuccess = ok; if (ok) { _invNewQty = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); } } + + private async Task DoRecordWastage() + { + _invFormMessage = null; + if (_wastageSelectedItemId == Guid.Empty || _wastageAmount <= 0 || string.IsNullOrWhiteSpace(_wastageReason)) + { + _invFormMessage = "Vui lòng chọn nguyên liệu, nhập số lượng > 0 và chọn lý do."; _invFormSuccess = false; return; + } + var ok = await DataService.RecordWastageAsync(new PosDataService.RecordWastageRequest( + _wastageSelectedItemId, _wastageAmount, _wastageReason, + string.IsNullOrWhiteSpace(_wastageNotes) ? null : _wastageNotes)); + _invFormMessage = ok ? $"Đã ghi nhận hao hụt -{_wastageAmount}!" : "Lỗi khi ghi nhận hao hụt."; + _invFormSuccess = ok; + if (ok) { _wastageAmount = 0; _wastageReason = ""; _wastageNotes = ""; _wastageSelectedItemId = Guid.Empty; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); } + } + + private int GetStocktakeCount(Guid itemId) => _stocktakeCounts.TryGetValue(itemId, out var v) ? v : 0; + + private void SetStocktakeCount(Guid itemId, int value) => _stocktakeCounts[itemId] = value; + + private async Task DoStocktake() + { + _invFormMessage = null; + _stocktakeResult = null; + var items = _inventory + .Where(i => _stocktakeCounts.ContainsKey(i.Id)) + .Select(i => new PosDataService.StocktakeItemRequest(i.Id, _stocktakeCounts[i.Id])) + .ToList(); + if (!items.Any()) { _invFormMessage = "Vui lòng nhập số lượng đếm cho ít nhất 1 mặt hàng."; _invFormSuccess = false; return; } + var result = await DataService.StocktakeAsync(new PosDataService.StocktakeRequest(ShopId, items)); + if (result != null) + { + _stocktakeResult = result; + _invFormMessage = $"Kiểm kê hoàn thành: {result.TotalItemsCounted} mặt hàng, {result.Discrepancies.Count} chênh lệch."; + _invFormSuccess = true; + _stocktakeCounts.Clear(); + _inventory = await DataService.GetInventoryAsync(_shopGuid); + _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); + } + else { _invFormMessage = "Lỗi khi thực hiện kiểm kê."; _invFormSuccess = false; } + } + + private async Task DoDeleteInventoryItem(Guid itemId) + { + var item = _inventory.FirstOrDefault(i => i.Id == itemId); + var name = item?.ProductName ?? "nguyên liệu"; + // Simple confirmation by checking quantity + if (item != null && item.Quantity > 0) + { + _invFormMessage = $"⚠️ Không thể xóa '{name}' vì còn {item.Quantity} {item.Unit} trong kho. Hãy xuất hết trước."; + _invFormSuccess = false; + StateHasChanged(); + return; + } + var ok = await DataService.DeleteInventoryItemAsync(itemId); + if (ok) + { + _invFormMessage = $"✅ Đã xóa '{name}' khỏi tồn kho!"; + _invFormSuccess = true; + _inventory = await DataService.GetInventoryAsync(_shopGuid); + } + else + { + _invFormMessage = $"❌ Không thể xóa '{name}'."; + _invFormSuccess = false; + } + StateHasChanged(); + } + + // EN: Create a new inventory item (raw material / supply) / VI: Tạo nguyên liệu / vật tư mới + private async Task DoCreateInventoryItem() + { + _invFormMessage = null; + if (string.IsNullOrWhiteSpace(_createName)) + { + _invFormMessage = "Vui lòng nhập tên nguyên liệu."; _invFormSuccess = false; return; + } + var req = new PosDataService.CreateInventoryItemRequest( + ShopId, _createName.Trim(), _createItemTypeId, _createUnit, + _createCostPerUnit, _createInitialQty, _createReorderLevel, + string.IsNullOrWhiteSpace(_createSupplier) ? null : _createSupplier.Trim(), + _createExpiryDate); + var ok = await DataService.CreateInventoryItemAsync(req); + _invFormMessage = ok ? $"Đã tạo nguyên liệu \"{_createName}\" thành công!" : "Lỗi khi tạo nguyên liệu."; + _invFormSuccess = ok; + if (ok) + { + _createName = ""; _createCostPerUnit = 0; _createInitialQty = 0; _createReorderLevel = 10; + _createSupplier = ""; _createExpiryDate = null; _showCreateForm = false; + _inventory = await DataService.GetInventoryAsync(_shopGuid); + _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); + } + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopRecipes.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopRecipes.razor index b02a8fcc..2057624e 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopRecipes.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopRecipes.razor @@ -1,3 +1,5 @@ +@* EN: Recipe management component — link ingredients to inventory items for COGS calculation. + VI: Component quản lý công thức — liên kết nguyên liệu với mặt hàng tồn kho để tính giá vốn. *@ @using WebClientTpos.Client.Services @using WebClientTpos.Client.Pages.Admin.Shop @inject PosDataService DataService @@ -19,17 +21,31 @@
-
Nguyên liệu
+
Nguyên liệu / Ingredients
@for (var idx = 0; idx < _recipeIngredients.Count; idx++) { var i = idx; -
- - - - - +
+ @* EN: Inventory item dropdown — select raw material from inventory / VI: Dropdown chọn nguyên liệu từ kho *@ + + + + + +
+ @if (_recipeIngredients[i].InventoryItemId != null) + { +
+ Qty/serving: +
+ } }
@@ -72,14 +88,26 @@ else @code { [Parameter] public Guid ShopId { get; set; } - // Recipes state + // EN: Mutable class for ingredient row editing / VI: Class có thể chỉnh sửa cho hàng nguyên liệu + private class IngredientRow + { + public string Name { get; set; } = ""; + public string Unit { get; set; } = ""; + public decimal Quantity { get; set; } + public decimal Cost { get; set; } + public Guid? InventoryItemId { get; set; } + public decimal QuantityPerServing { get; set; } + } + + // EN: Recipes state / VI: Trạng thái công thức private List _recipes = new(); + private List _inventoryItems = new(); private bool _showRecipeForm; private Guid? _editingRecipeId; private string _newRecipeName = ""; private string _newRecipeInstructions = ""; private int _newRecipePrepTime = 5; - private List<(string Name, string Unit, string Qty, decimal Quantity, decimal Cost)> _recipeIngredients = new(); + private List _recipeIngredients = new(); private Guid? _expandedRecipeId; private string? _recipeFormMessage; private bool _recipeFormSuccess; @@ -88,7 +116,34 @@ else protected override async Task OnInitializedAsync() { if (ShopId != Guid.Empty) - _recipes = await DataService.GetRecipesAsync(ShopId); + { + // EN: Load recipes and inventory items in parallel. + // VI: Tải công thức và mặt hàng tồn kho song song. + var recipesTask = DataService.GetRecipesAsync(ShopId); + var inventoryTask = DataService.GetInventoryAsync(ShopId); + await Task.WhenAll(recipesTask, inventoryTask); + _recipes = recipesTask.Result; + _inventoryItems = inventoryTask.Result; + } + } + + // EN: When user selects an inventory item, auto-fill name, unit, cost. + // VI: Khi người dùng chọn mặt hàng tồn kho, tự động điền tên, đơn vị, chi phí. + private void OnInventoryItemSelected(int index, string? value) + { + if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var itemId)) + { + _recipeIngredients[index].InventoryItemId = null; + return; + } + + var item = _inventoryItems.FirstOrDefault(x => x.Id == itemId); + if (item == null) return; + + _recipeIngredients[index].InventoryItemId = itemId; + _recipeIngredients[index].Name = item.ProductName ?? ""; + _recipeIngredients[index].Unit = item.Unit ?? ""; + _recipeIngredients[index].Cost = item.CostPerUnit ?? 0; } // ═══ RECIPE CRUD ═══ @@ -103,7 +158,7 @@ else { var ingredients = _recipeIngredients .Where(i => !string.IsNullOrWhiteSpace(i.Name)) - .Select(i => new PosDataService.RecipeIngredientRequest(i.Name, i.Quantity, i.Unit, i.Cost)) + .Select(i => new PosDataService.RecipeIngredientRequest(i.Name, i.Quantity, i.Unit, i.Cost, i.InventoryItemId, i.QuantityPerServing)) .ToList(); var req = new PosDataService.CreateRecipeRequest(ShopId, Guid.Empty, _newRecipeName, _newRecipeInstructions, _newRecipePrepTime, ingredients); bool ok; 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 c93f5f46..51a6e2f3 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 @@ -222,8 +222,17 @@ public class PosDataService // ═══ INVENTORY METHODS ═══ + // EN: Inventory item info — includes item type, unit, cost and supplier metadata. + // VI: Thông tin mặt hàng tồn kho — bao gồm loại, đơn vị, giá nhập và nhà cung cấp. public record InventoryItemInfo(Guid Id, Guid ProductId, Guid ShopId, int Quantity, - int ReorderLevel, int ReservedQuantity, DateTime? UpdatedAt, string? ProductName); + int ReorderLevel, int ReservedQuantity, DateTime? UpdatedAt, string? ProductName, + string? ItemType = null, string? Unit = null, decimal? CostPerUnit = null, + string? SupplierName = null, DateTime? ExpiryDate = null); + + // EN: Request to create a new inventory item (raw material / supply). + // VI: Yêu cầu tạo mặt hàng tồn kho mới (nguyên liệu / vật tư). + public record CreateInventoryItemRequest(Guid ShopId, string Name, int ItemTypeId, string Unit, + decimal CostPerUnit, int InitialQuantity, int ReorderLevel, string? SupplierName, DateTime? ExpiryDate); public async Task> GetInventoryAsync(Guid? shopId = null) { @@ -231,6 +240,22 @@ public class PosDataService return await GetListFromApiAsync(url); } + // EN: Create a new inventory item (raw material, finished product, or supply). + // VI: Tạo mặt hàng tồn kho mới (nguyên liệu, thành phẩm, hoặc vật tư). + public async Task CreateInventoryItemAsync(CreateInventoryItemRequest req) + { + AttachToken(); + var r = await _http.PostAsJsonAsync("api/bff/inventory/items", req, _writeOptions); + return r.IsSuccessStatusCode; + } + + public async Task DeleteInventoryItemAsync(Guid inventoryItemId) + { + AttachToken(); + var resp = await _http.DeleteAsync($"api/bff/inventory/items/{inventoryItemId}"); + return resp.IsSuccessStatusCode; + } + // ═══ MEMBERSHIP/CUSTOMER METHODS ═══ public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp, @@ -412,7 +437,10 @@ public class PosDataService // ═══ INVENTORY OPERATIONS ═══ - public record StockInRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes); + // EN: Stock-in request — includes optional invoice image URL and unit cost. + // VI: Yêu cầu nhập kho — bao gồm URL hóa đơn và giá nhập tùy chọn. + public record StockInRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes, + string? InvoiceImageUrl = null, decimal? UnitCost = null, string? SupplierName = null); public async Task StockInAsync(StockInRequest req) { @@ -447,6 +475,38 @@ public class PosDataService return await GetListFromApiAsync(url); } + // ═══ WASTAGE & STOCKTAKE ═══ + + public record RecordWastageRequest(Guid InventoryItemId, int Amount, string Reason, string? Notes); + + public async Task RecordWastageAsync(RecordWastageRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/inventory/wastage", req, _writeOptions); + return resp.IsSuccessStatusCode; + } + + public record StocktakeItemRequest(Guid InventoryItemId, int CountedQuantity); + public record StocktakeRequest(Guid ShopId, List Items); + public record StocktakeDiscrepancy(Guid InventoryItemId, string? ItemName, int ExpectedQuantity, int CountedQuantity, int Difference); + public record StocktakeResult(List Discrepancies, int TotalItemsCounted); + + public async Task StocktakeAsync(StocktakeRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/inventory/stocktake", req, _writeOptions); + if (!resp.IsSuccessStatusCode) return null; + try + { + var json = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("data", out var data)) + return JsonSerializer.Deserialize(data.GetRawText(), _jsonOptions); + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch { return null; } + } + // ═══ MEMBERSHIP LEVELS ═══ public record LevelDefinitionInfo(Guid Id, int LevelNumber, string Name, int RequiredExp, @@ -880,10 +940,14 @@ public class PosDataService // ═══ RECIPES CRUD ═══ - public record RecipeIngredientInfo(Guid Id, Guid RecipeId, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); + // EN: Recipe ingredient info — includes optional inventory item link for COGS. + // VI: Thông tin nguyên liệu công thức — bao gồm liên kết tùy chọn đến mặt hàng tồn kho để tính giá vốn. + public record RecipeIngredientInfo(Guid Id, Guid RecipeId, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit, + Guid? InventoryItemId = null, decimal QuantityPerServing = 0); public record RecipeInfo(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions, int PrepTimeMinutes, bool IsActive, DateTime CreatedAt); public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List? Ingredients); - public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); + public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit, + Guid? InventoryItemId = null, decimal QuantityPerServing = 0); public async Task> GetRecipesAsync(Guid? shopId = null) { 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 72cb8421..3bdfcf53 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 @@ -89,13 +89,24 @@ public class InventoryController : ControllerBase var obj = new Dictionary(); foreach (var kv in dict) obj[kv.Key] = kv.Value; - obj["productName"] = productName; + // EN: Use item name (raw material) if available, fallback to catalog productName + // VI: Dùng tên item (nguyên liệu) nếu có, fallback sang productName từ catalog + var itemName = item.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String ? nameEl.GetString() : null; + obj["productName"] = itemName ?? productName; enriched.Add(obj); } return Ok(new { success = true, data = new { items = enriched } }); } + /// + /// EN: Create a new inventory item (raw material, finished product, or supply). + /// VI: Tạo mặt hàng tồn kho mới (nguyên liệu, thành phẩm, hoặc vật tư). + /// + [HttpPost("inventory/items")] + public Task CreateInventoryItem([FromBody] JsonElement body) => + _inventory.PostAsJsonAsync("/api/v1/inventory/items", body).ProxyAsync(); + /// /// EN: Update inventory quantity for a specific item. /// VI: Cập nhật số lượng tồn kho cho mặt hàng. @@ -139,6 +150,22 @@ public class InventoryController : ControllerBase public Task AdjustStock([FromBody] JsonElement body) => _inventory.PostAsJsonAsync("/api/v1/inventory/adjust", body).ProxyAsync(); + /// + /// EN: Record wastage/shrinkage. + /// VI: Ghi nhận hao hụt. + /// + [HttpPost("inventory/wastage")] + public Task RecordWastage([FromBody] JsonElement body) => + _inventory.PostAsJsonAsync("/api/v1/inventory/wastage", body).ProxyAsync(); + + /// + /// EN: Perform stocktake (inventory count). + /// VI: Thực hiện kiểm kê (đếm tồn kho). + /// + [HttpPost("inventory/stocktake")] + public Task Stocktake([FromBody] JsonElement body) => + _inventory.PostAsJsonAsync("/api/v1/inventory/stocktake", body).ProxyAsync(); + /// /// EN: Get low stock alerts. /// VI: Lấy cảnh báo hàng tồn kho thấp. @@ -149,4 +176,12 @@ public class InventoryController : ControllerBase var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; return _inventory.GetAsync($"/api/v1/inventory/low-stock{qs}").ProxyAsync(); } + + /// + /// EN: Delete an inventory item. + /// VI: Xóa nguyên liệu khỏi tồn kho. + /// + [HttpDelete("inventory/items/{id:guid}")] + public Task DeleteInventoryItem(Guid id) => + _inventory.DeleteAsync($"/api/v1/inventory/items/{id}").ProxyAsync(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs index e767ddcd..eca8aca6 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -213,6 +213,12 @@ public class OrderController : ControllerBase { writer.WriteString("productType", itemType); } + // EN: Default all PreparedFood items to track inventory for auto-deduction + // VI: Mặc định tất cả PreparedFood items theo dõi tồn kho để tự động trừ kho + if (!item.TryGetProperty("trackInventory", out _)) + { + writer.WriteBoolean("trackInventory", true); + } writer.WriteEndObject(); } writer.WriteEndArray(); 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 index e0c93b34..3d483fbd 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommandHandlers.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/RecipeCommandHandlers.cs @@ -14,7 +14,7 @@ public class CreateRecipeCommandHandler : IRequestHandler public record DeleteRecipeCommand(Guid RecipeId) : IRequest; -public record IngredientItem(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); +// EN: Ingredient item DTO — includes optional inventory item link for COGS. +// VI: DTO nguyên liệu — bao gồm liên kết tùy chọn đến mặt hàng tồn kho để tính giá vốn. +public record IngredientItem(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit, + Guid? InventoryItemId = null, decimal QuantityPerServing = 0); 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 index 3f4dc188..68921dfc 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/RecipeQueries.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/RecipeQueries.cs @@ -4,11 +4,15 @@ using FnbEngine.Domain.AggregatesModel.RecipeAggregate; namespace FnbEngine.API.Application.Queries; public record GetRecipesByShopQuery(Guid ShopId) : IRequest>; +public record GetRecipeByProductQuery(Guid ProductId, 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); +// EN: Recipe ingredient DTO — includes optional inventory item link for COGS. +// VI: DTO nguyên liệu — bao gồm liên kết tùy chọn đến mặt hàng tồn kho để tính giá vốn. +public record RecipeIngredientDto(Guid Id, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit, + Guid? InventoryItemId = null, decimal QuantityPerServing = 0); public class GetRecipesByShopQueryHandler : IRequestHandler> { @@ -20,7 +24,23 @@ public class GetRecipesByShopQueryHandler : IRequestHandler 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() + r.Ingredients.Select(i => new RecipeIngredientDto(i.Id, i.IngredientName, i.Quantity, i.Unit, i.CostPerUnit, i.InventoryItemId, i.QuantityPerServing)).ToList() )); } } + +public class GetRecipeByProductQueryHandler : IRequestHandler +{ + private readonly IRecipeRepository _repo; + public GetRecipeByProductQueryHandler(IRecipeRepository repo) => _repo = repo; + + public async Task Handle(GetRecipeByProductQuery req, CancellationToken ct) + { + var r = await _repo.GetByProductIdAndShopAsync(req.ProductId, req.ShopId, ct); + if (r == null) return null; + return 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, i.InventoryItemId, i.QuantityPerServing)).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 43c48043..dd721234 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs @@ -1,7 +1,6 @@ // EN: Controller for kitchen display system. // VI: Controller cho hệ thống hiển thị bếp. -using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Mvc; using FnbEngine.API.Application.Commands; @@ -11,8 +10,7 @@ using FnbEngine.Domain.AggregatesModel.SessionAggregate; namespace FnbEngine.API.Controllers; [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/kitchen")] +[Route("api/v1/kitchen")] public class KitchenController : ControllerBase { private readonly IMediator _mediator; @@ -112,6 +110,22 @@ public class KitchenController : ControllerBase return Ok(new ApiResponse> { Success = true, Data = result }); } + /// + /// EN: Get recipe by product ID and shop ID (for inventory deduction). + /// VI: Lấy công thức theo product ID và shop ID (cho trừ kho). + /// + [HttpGet("recipes/by-product")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + public async Task>> GetRecipeByProduct( + [FromQuery] Guid productId, [FromQuery] Guid shopId, CancellationToken ct = default) + { + var result = await _mediator.Send(new GetRecipeByProductQuery(productId, shopId), ct); + if (result == null) + return NotFound(new ApiResponse { Success = false, Error = "No recipe found for this product" }); + return Ok(new ApiResponse { Success = true, Data = result }); + } + [HttpPost("recipes")] [ProducesResponseType(typeof(ApiResponse), 200)] public async Task>> CreateRecipe( diff --git a/services/fnb-engine-net/src/FnbEngine.API/Controllers/ReservationsController.cs b/services/fnb-engine-net/src/FnbEngine.API/Controllers/ReservationsController.cs index 69d2e716..c4fbbeb8 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Controllers/ReservationsController.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Controllers/ReservationsController.cs @@ -1,7 +1,6 @@ // EN: Controller for reservation management. // VI: Controller quản lý đặt bàn. -using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Mvc; using FnbEngine.API.Application.Commands; @@ -11,8 +10,7 @@ using FnbEngine.Domain.AggregatesModel.ReservationAggregate; namespace FnbEngine.API.Controllers; [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/reservations")] +[Route("api/v1/reservations")] public class ReservationsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Controllers/SessionsController.cs b/services/fnb-engine-net/src/FnbEngine.API/Controllers/SessionsController.cs index 4b4a6f50..56697b64 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Controllers/SessionsController.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Controllers/SessionsController.cs @@ -1,7 +1,6 @@ // EN: Controller for session management. // VI: Controller quản lý phiên. -using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Mvc; using FnbEngine.API.Application.Commands; @@ -14,8 +13,7 @@ namespace FnbEngine.API.Controllers; /// VI: Controller quản lý phiên. /// [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/sessions")] +[Route("api/v1/sessions")] public class SessionsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Controllers/TablesController.cs b/services/fnb-engine-net/src/FnbEngine.API/Controllers/TablesController.cs index f0a22142..c358660f 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Controllers/TablesController.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Controllers/TablesController.cs @@ -1,7 +1,6 @@ // EN: Controller for table management. // VI: Controller quản lý bàn. -using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Mvc; using FnbEngine.API.Application.Commands; @@ -15,8 +14,7 @@ namespace FnbEngine.API.Controllers; /// VI: Controller quản lý bàn. /// [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/tables")] +[Route("api/v1/tables")] public class TablesController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/fnb-engine-net/src/FnbEngine.API/FnbEngine.API.csproj b/services/fnb-engine-net/src/FnbEngine.API/FnbEngine.API.csproj index 9be4c7bf..c915dc03 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/FnbEngine.API.csproj +++ b/services/fnb-engine-net/src/FnbEngine.API/FnbEngine.API.csproj @@ -25,10 +25,6 @@ - - - - diff --git a/services/fnb-engine-net/src/FnbEngine.API/Program.cs b/services/fnb-engine-net/src/FnbEngine.API/Program.cs index 264308d0..f7511cb5 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Program.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Program.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Asp.Versioning; using FluentValidation; using Hellang.Middleware.ProblemDetails; using FnbEngine.API.Application.Behaviors; @@ -43,22 +42,6 @@ try // EN: Add FluentValidation / VI: Thêm FluentValidation builder.Services.AddValidatorsFromAssemblyContaining(); - // EN: Add API versioning / VI: Thêm API versioning - builder.Services.AddApiVersioning(options => - { - options.DefaultApiVersion = new ApiVersion(1, 0); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ReportApiVersions = true; - options.ApiVersionReader = ApiVersionReader.Combine( - new UrlSegmentApiVersionReader(), - new HeaderApiVersionReader("X-Api-Version")); - }) - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - }); - // EN: Add controllers / VI: Thêm controllers builder.Services.AddControllers(); 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 index a37c49bb..c0f4c043 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/IRecipeRepository.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/IRecipeRepository.cs @@ -9,4 +9,5 @@ public interface IRecipeRepository : IRepository void Delete(Recipe recipe); Task GetByIdAsync(Guid id, CancellationToken ct = default); Task> GetByShopIdAsync(Guid shopId, CancellationToken ct = default); + Task GetByProductIdAndShopAsync(Guid productId, 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 index cb0da74e..0d7e7d87 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/Recipe.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/Recipe.cs @@ -38,17 +38,21 @@ public class Recipe : Entity, IAggregateRoot _createdAt = DateTime.UtcNow; } - public void Update(string name, string? instructions, int prepTimeMinutes) + public void Update(Guid productId, string name, string? instructions, int prepTimeMinutes) { + _productId = productId; _name = name; _instructions = instructions; _prepTimeMinutes = prepTimeMinutes; _updatedAt = DateTime.UtcNow; } - public void AddIngredient(string ingredientName, decimal quantity, string unit, decimal costPerUnit) + // EN: Add ingredient with optional inventory item link for COGS calculation. + // VI: Thêm nguyên liệu với liên kết tùy chọn đến mặt hàng tồn kho để tính giá vốn. + public void AddIngredient(string ingredientName, decimal quantity, string unit, decimal costPerUnit, + Guid? inventoryItemId = null, decimal quantityPerServing = 0) { - _ingredients.Add(new RecipeIngredient(Id, ingredientName, quantity, unit, costPerUnit)); + _ingredients.Add(new RecipeIngredient(Id, ingredientName, quantity, unit, costPerUnit, inventoryItemId, quantityPerServing)); } public void ClearIngredients() 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 index ec54ae49..0e0a8cd9 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/RecipeIngredient.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/RecipeAggregate/RecipeIngredient.cs @@ -2,6 +2,8 @@ using FnbEngine.Domain.SeedWork; namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate; +// EN: Recipe ingredient entity — links to an optional inventory item for COGS calculation. +// VI: Entity nguyên liệu công thức — liên kết với mặt hàng tồn kho (tùy chọn) để tính giá vốn. public class RecipeIngredient : Entity { private Guid _recipeId; @@ -9,16 +11,25 @@ public class RecipeIngredient : Entity private decimal _quantity; private string _unit = null!; private decimal _costPerUnit; + private Guid? _inventoryItemId; + private decimal _quantityPerServing; public Guid RecipeId => _recipeId; public string IngredientName => _ingredientName; public decimal Quantity => _quantity; public string Unit => _unit; public decimal CostPerUnit => _costPerUnit; + // EN: Optional link to inventory item for auto-deduction and COGS tracking. + // VI: Liên kết tùy chọn đến mặt hàng tồn kho để trừ kho tự động và theo dõi giá vốn. + public Guid? InventoryItemId => _inventoryItemId; + // EN: Quantity consumed per serving (for COGS calculation). + // VI: Số lượng tiêu thụ mỗi phần (để tính giá vốn). + public decimal QuantityPerServing => _quantityPerServing; protected RecipeIngredient() { } - public RecipeIngredient(Guid recipeId, string ingredientName, decimal quantity, string unit, decimal costPerUnit) + public RecipeIngredient(Guid recipeId, string ingredientName, decimal quantity, string unit, + decimal costPerUnit, Guid? inventoryItemId = null, decimal quantityPerServing = 0) { Id = Guid.NewGuid(); _recipeId = recipeId; @@ -26,5 +37,7 @@ public class RecipeIngredient : Entity _quantity = quantity; _unit = unit; _costPerUnit = costPerUnit; + _inventoryItemId = inventoryItemId; + _quantityPerServing = quantityPerServing; } } diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs index 279ec2ff..0cb9993b 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/RecipeIngredientEntityTypeConfiguration.cs @@ -17,6 +17,10 @@ public class RecipeIngredientEntityTypeConfiguration : IEntityTypeConfiguration< 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)"); + // EN: Optional inventory item link for COGS calculation / VI: Liên kết tùy chọn đến mặt hàng tồn kho để tính giá vốn + builder.Property(ri => ri.InventoryItemId).HasField("_inventoryItemId").HasColumnName("inventory_item_id"); + builder.Property(ri => ri.QuantityPerServing).HasField("_quantityPerServing").HasColumnName("quantity_per_serving").HasColumnType("decimal(18,4)"); + builder.Ignore(ri => ri.DomainEvents); } } diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs index 81ffe988..febccfdc 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/RecipeRepository.cs @@ -23,4 +23,8 @@ public class RecipeRepository : IRecipeRepository .Where(r => r.ShopId == shopId && r.IsActive) .OrderBy(r => r.Name) .ToListAsync(ct); + + public async Task GetByProductIdAndShopAsync(Guid productId, Guid shopId, CancellationToken ct = default) => + await _context.Recipes.Include(r => r.Ingredients) + .FirstOrDefaultAsync(r => r.ProductId == productId && r.ShopId == shopId && r.IsActive, ct); } diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs index 36a957da..7acb4115 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs @@ -2,11 +2,57 @@ // VI: Command handlers cho Inventory Service. using InventoryService.Domain.AggregatesModel.InventoryAggregate; +using InventoryService.Domain.SeedWork; using MediatR; using Microsoft.Extensions.Logging; namespace InventoryService.API.Application.Commands; +/// +/// EN: Handler for CreateInventoryItemCommand. +/// VI: Handler cho CreateInventoryItemCommand. +/// +public class CreateInventoryItemCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public CreateInventoryItemCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(CreateInventoryItemCommand request, CancellationToken ct) + { + // EN: Resolve ItemType from ID + // VI: Xác định ItemType từ ID + var itemType = Enumeration.FromValue(request.ItemTypeId); + + var item = new InventoryItem( + request.ShopId, + request.Name, + itemType, + request.Unit, + request.CostPerUnit, + request.InitialQuantity, + request.ReorderLevel, + request.SupplierName, + request.ExpiryDate); + + await _repository.AddAsync(item, ct); + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Inventory item created / VI: Inventory item đã tạo - Name: {Name}, Shop: {ShopId}, Type: {ItemType}", + request.Name, request.ShopId, itemType.Name); + + return item.Id; + } +} + /// /// EN: Handler for StockInCommand. /// VI: Handler cho StockInCommand. @@ -29,8 +75,8 @@ public class StockInCommandHandler : IRequestHandler // EN: Get or create inventory item // VI: Lấy hoặc tạo inventory item var item = await _repository.GetByProductAndShopAsync( - request.ProductId, - request.ShopId, + request.ProductId, + request.ShopId, ct); if (item == null) @@ -39,9 +85,9 @@ public class StockInCommandHandler : IRequestHandler await _repository.AddAsync(item, ct); } - // EN: Perform stock in operation - // VI: Thực hiện nhập kho - item.StockIn(request.Amount, request.Notes, request.ReferenceId); + // EN: Perform stock in operation with invoice and unit cost + // VI: Thực hiện nhập kho với hóa đơn và giá đơn vị + item.StockIn(request.Amount, request.Notes, request.ReferenceId, request.InvoiceImageUrl, request.UnitCost); await _repository.UnitOfWork.SaveChangesAsync(ct); @@ -208,3 +254,146 @@ public class AdjustStockCommandHandler : IRequestHandler +/// EN: Handler for StockOutByIdCommand - deducts by inventory item ID directly. +/// VI: Handler cho StockOutByIdCommand - trừ kho theo inventory item ID trực tiếp. +/// +public class StockOutByIdCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public StockOutByIdCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(StockOutByIdCommand request, CancellationToken ct) + { + var item = await _repository.GetByIdAsync(request.InventoryItemId, ct); + if (item == null) return false; + + item.StockOut(request.Amount, request.Notes, request.ReferenceId); + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Stock out by ID completed / VI: Xuất kho theo ID hoàn thành - ItemId: {ItemId}, Amount: {Amount}", + request.InventoryItemId, request.Amount); + + return true; + } +} + +/// +/// EN: Handler for RecordWastageCommand. +/// VI: Handler cho RecordWastageCommand. +/// +public class RecordWastageCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public RecordWastageCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(RecordWastageCommand request, CancellationToken ct) + { + var item = await _repository.GetByIdAsync(request.InventoryItemId, ct); + if (item == null) return false; + + item.RecordWastage(request.Amount, request.Reason, request.Notes); + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Wastage recorded / VI: Hao hụt đã ghi nhận - ItemId: {ItemId}, Amount: {Amount}, Reason: {Reason}", + request.InventoryItemId, request.Amount, request.Reason); + + return true; + } +} + +/// +/// EN: Handler for StocktakeCommand. +/// VI: Handler cho StocktakeCommand. +/// +public class StocktakeCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public StocktakeCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(StocktakeCommand request, CancellationToken ct) + { + var discrepancies = new List(); + + foreach (var stocktakeItem in request.Items) + { + var item = await _repository.GetByIdAsync(stocktakeItem.InventoryItemId, ct); + if (item == null) continue; + + var expectedQty = item.Quantity; + var diff = stocktakeItem.CountedQuantity - expectedQty; + + if (diff != 0) + { + discrepancies.Add(new StocktakeDiscrepancy( + item.Id, item.Name, expectedQty, stocktakeItem.CountedQuantity, diff)); + } + + item.Adjust(stocktakeItem.CountedQuantity, "Stocktake"); + } + + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Stocktake completed / VI: Kiểm kê hoàn thành - Shop: {ShopId}, Items: {Count}, Discrepancies: {Discrepancies}", + request.ShopId, request.Items.Count, discrepancies.Count); + + return new StocktakeResult(discrepancies, request.Items.Count); + } +} + +/// +/// EN: Handler for DeleteInventoryItemCommand. +/// VI: Handler cho DeleteInventoryItemCommand. +/// +public class DeleteInventoryItemCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public DeleteInventoryItemCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(DeleteInventoryItemCommand request, CancellationToken cancellationToken) + { + var item = await _repository.GetByIdAsync(request.InventoryItemId); + if (item == null) return false; + + _repository.Delete(item); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + _logger.LogInformation("EN: Inventory item deleted / VI: Đã xóa nguyên liệu: {Id}", request.InventoryItemId); + return true; + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs index f8c8a0ac..938e15e4 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs @@ -5,6 +5,21 @@ using MediatR; namespace InventoryService.API.Application.Commands; +/// +/// EN: Command to create a new inventory item (raw materials / consumables). +/// VI: Command để tạo inventory item mới (nguyên liệu thô / vật tư tiêu hao). +/// +public record CreateInventoryItemCommand( + Guid ShopId, + string Name, + int ItemTypeId, + string Unit, + decimal CostPerUnit, + int InitialQuantity, + int ReorderLevel, + string? SupplierName, + DateTime? ExpiryDate) : IRequest; + /// /// EN: Command to perform stock in operation. /// VI: Command để thực hiện nhập kho. @@ -14,7 +29,9 @@ public record StockInCommand( Guid ShopId, int Amount, string? Notes, - Guid? ReferenceId) : IRequest; + Guid? ReferenceId, + string? InvoiceImageUrl, + decimal? UnitCost) : IRequest; /// /// EN: Command to perform stock out operation. @@ -56,3 +73,37 @@ public record AdjustStockCommand( Guid ShopId, int NewQuantity, string Notes) : IRequest; + +/// +/// EN: Command to stock out by inventory item ID directly (for recipe deduction). +/// VI: Command xuất kho theo inventory item ID trực tiếp (cho trừ nguyên liệu công thức). +/// +public record StockOutByIdCommand( + Guid InventoryItemId, + int Amount, + string? Notes, + Guid? ReferenceId) : IRequest; + +/// +/// EN: Command to record wastage/shrinkage. +/// VI: Command để ghi nhận hao hụt. +/// +public record RecordWastageCommand(Guid InventoryItemId, int Amount, string Reason, string? Notes) : IRequest; + +/// +/// EN: Command to perform stocktake (inventory count). +/// VI: Command để thực hiện kiểm kê (đếm tồn kho). +/// +public record StocktakeCommand(Guid ShopId, List Items) : IRequest; + +public record StocktakeItem(Guid InventoryItemId, int CountedQuantity); + +public record StocktakeResult(List Discrepancies, int TotalItemsCounted); + +public record StocktakeDiscrepancy(Guid InventoryItemId, string? ItemName, int ExpectedQuantity, int CountedQuantity, int Difference); + +/// +/// EN: Command to delete an inventory item. +/// VI: Command để xóa một inventory item. +/// +public record DeleteInventoryItemCommand(Guid InventoryItemId) : IRequest; diff --git a/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs index d731abf4..0cb5b9a3 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs @@ -11,6 +11,13 @@ public record InventoryItemDto( Guid Id, Guid ProductId, Guid ShopId, + string? Name, + string ItemType, + int ItemTypeId, + string Unit, + decimal CostPerUnit, + string? SupplierName, + DateTime? ExpiryDate, int Quantity, int ReservedQuantity, int AvailableQuantity, @@ -28,6 +35,8 @@ public record InventoryTransactionDto( int Quantity, Guid? ReferenceId, string? Notes, + string? InvoiceImageUrl, + decimal? UnitCost, DateTime CreatedAt); /// @@ -39,7 +48,9 @@ public record StockInRequest( Guid ShopId, int Amount, string? Notes, - Guid? ReferenceId); + Guid? ReferenceId, + string? InvoiceImageUrl, + decimal? UnitCost); /// /// EN: Request for stock out operation. @@ -82,10 +93,37 @@ public record AdjustStockRequest( int NewQuantity, string Notes); +/// +/// EN: Request for creating a new inventory item (raw materials / consumables). +/// VI: Request cho tạo inventory item mới (nguyên liệu thô / vật tư tiêu hao). +/// +public record StockOutByIdRequest( + Guid InventoryItemId, + int Amount, + string? Notes = null, + Guid? ReferenceId = null); + +public record CreateInventoryItemRequest( + Guid ShopId, + string Name, + int ItemTypeId, + string Unit, + decimal CostPerUnit, + int InitialQuantity = 0, + int ReorderLevel = 10, + string? SupplierName = null, + DateTime? ExpiryDate = null); + /// /// EN: Standard API response wrapper. /// VI: Wrapper response API chuẩn. /// +public record RecordWastageRequest(Guid InventoryItemId, int Amount, string Reason, string? Notes); + +public record StocktakeRequest(Guid ShopId, List Items); + +public record StocktakeItemRequest(Guid InventoryItemId, int CountedQuantity); + public class ApiResponse { public bool Success { get; set; } diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Mappers/InventoryMapper.cs b/services/inventory-service-net/src/InventoryService.API/Application/Mappers/InventoryMapper.cs index 81307e75..2142e3f7 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Mappers/InventoryMapper.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Mappers/InventoryMapper.cs @@ -20,6 +20,13 @@ public static class InventoryMapper item.Id, item.ProductId, item.ShopId, + item.Name, + item.ItemType?.Name ?? ResolveItemTypeName(item.ItemTypeId), + item.ItemTypeId, + item.Unit, + item.CostPerUnit, + item.SupplierName, + item.ExpiryDate, item.Quantity, item.ReservedQuantity, item.AvailableQuantity, @@ -37,5 +44,15 @@ public static class InventoryMapper transaction.Quantity, transaction.ReferenceId, transaction.Notes, + transaction.InvoiceImageUrl, + transaction.UnitCost, transaction.CreatedAt); + + private static string ResolveItemTypeName(int itemTypeId) => itemTypeId switch + { + 1 => "RawMaterial", + 2 => "FinishedGood", + 3 => "Consumable", + _ => "Unknown" + }; } diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs b/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs index 40000017..f1c0983e 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs @@ -6,6 +6,48 @@ using InventoryService.API.Application.Commands; namespace InventoryService.API.Application.Validations; +/// +/// EN: Validator for CreateInventoryItemCommand. +/// VI: Validator cho CreateInventoryItemCommand. +/// +public class CreateInventoryItemCommandValidator : AbstractValidator +{ + public CreateInventoryItemCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required / Tên bắt buộc") + .MaximumLength(200) + .WithMessage("Name must be 200 characters or less / Tên tối đa 200 ký tự"); + + RuleFor(x => x.ItemTypeId) + .InclusiveBetween(1, 3) + .WithMessage("Item type must be 1 (RawMaterial), 2 (FinishedGood), or 3 (Consumable) / Loại item phải là 1, 2 hoặc 3"); + + RuleFor(x => x.Unit) + .NotEmpty() + .WithMessage("Unit is required / Đơn vị bắt buộc") + .MaximumLength(20) + .WithMessage("Unit must be 20 characters or less / Đơn vị tối đa 20 ký tự"); + + RuleFor(x => x.CostPerUnit) + .GreaterThanOrEqualTo(0) + .WithMessage("Cost per unit must be >= 0 / Giá đơn vị phải >= 0"); + + RuleFor(x => x.InitialQuantity) + .GreaterThanOrEqualTo(0) + .WithMessage("Initial quantity must be >= 0 / Số lượng ban đầu phải >= 0"); + + RuleFor(x => x.ReorderLevel) + .GreaterThanOrEqualTo(0) + .WithMessage("Reorder level must be >= 0 / Mức đặt hàng lại phải >= 0"); + } +} + /// /// EN: Validator for StockInCommand. /// VI: Validator cho StockInCommand. diff --git a/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs index 001e9e4b..58481322 100644 --- a/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs +++ b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs @@ -1,7 +1,6 @@ // EN: Main controller for Inventory operations. // VI: Controller chính cho các thao tác Inventory. -using Asp.Versioning; using InventoryService.API.Application.Commands; using InventoryService.API.Application.DTOs; using InventoryService.API.Application.Queries; @@ -16,8 +15,7 @@ namespace InventoryService.API.Controllers; /// VI: Controller cho các thao tác inventory. /// [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/inventory")] +[Route("api/v1/inventory")] [SwaggerTag("Inventory Management - Stock operations, reservations, and tracking")] public class InventoryController : ControllerBase { @@ -77,6 +75,41 @@ public class InventoryController : ControllerBase return Ok(ApiResponse.Ok(result)); } + /// + /// EN: Create a new inventory item (raw material, consumable, etc.). + /// VI: Tạo mặt hàng tồn kho mới (nguyên liệu, vật tư tiêu hao, v.v.). + /// + [HttpPost("items")] + [SwaggerOperation(Summary = "Create a new inventory item")] + [SwaggerResponse(201, "Item created successfully")] + [SwaggerResponse(400, "Invalid request")] + public async Task>> CreateItem( + [FromBody] CreateInventoryItemRequest request, + CancellationToken ct = default) + { + try + { + var command = new CreateInventoryItemCommand( + request.ShopId, + request.Name, + request.ItemTypeId, + request.Unit, + request.CostPerUnit, + request.InitialQuantity, + request.ReorderLevel, + request.SupplierName, + request.ExpiryDate); + + var itemId = await _mediator.Send(command, ct); + return Created($"/api/v1/inventory/{itemId}", ApiResponse.Ok(itemId)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating inventory item"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + /// /// EN: Stock in operation (add inventory). /// VI: Thao tác nhập kho (thêm inventory). @@ -96,7 +129,9 @@ public class InventoryController : ControllerBase request.ShopId, request.Amount, request.Notes, - request.ReferenceId); + request.ReferenceId, + request.InvoiceImageUrl, + request.UnitCost); var inventoryItemId = await _mediator.Send(command, ct); @@ -145,6 +180,40 @@ public class InventoryController : ControllerBase } } + /// + /// EN: Stock out by inventory item ID (for recipe ingredient deduction). + /// VI: Xuất kho theo inventory item ID (cho trừ nguyên liệu công thức). + /// + [HttpPost("stock-out-by-id")] + [SwaggerOperation(Summary = "Deduct stock by inventory item ID")] + [SwaggerResponse(200, "Stock deducted successfully")] + [SwaggerResponse(400, "Invalid request or insufficient stock")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> StockOutById( + [FromBody] StockOutByIdRequest request, + CancellationToken ct = default) + { + try + { + var command = new StockOutByIdCommand( + request.InventoryItemId, + request.Amount, + request.Notes, + request.ReferenceId); + + var result = await _mediator.Send(command, ct); + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found")); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error performing stock out by ID"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + /// /// EN: Reserve stock for order. /// VI: Đặt trước stock cho order. @@ -250,6 +319,96 @@ public class InventoryController : ControllerBase } } + /// + /// EN: Record wastage/shrinkage. + /// VI: Ghi nhận hao hụt. + /// + [HttpPost("wastage")] + [SwaggerOperation(Summary = "Record wastage/shrinkage for an inventory item")] + [SwaggerResponse(200, "Wastage recorded successfully")] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> RecordWastage( + [FromBody] RecordWastageRequest request, + CancellationToken ct = default) + { + try + { + var command = new RecordWastageCommand( + request.InventoryItemId, + request.Amount, + request.Reason, + request.Notes); + + var result = await _mediator.Send(command, ct); + + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found")); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording wastage"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// EN: Perform stocktake (inventory count). + /// VI: Thực hiện kiểm kê (đếm tồn kho). + /// + [HttpPost("stocktake")] + [SwaggerOperation(Summary = "Perform stocktake and return discrepancies")] + [SwaggerResponse(200, "Stocktake completed successfully")] + [SwaggerResponse(400, "Invalid request")] + public async Task>> Stocktake( + [FromBody] StocktakeRequest request, + CancellationToken ct = default) + { + try + { + var command = new StocktakeCommand( + request.ShopId, + request.Items.Select(i => new StocktakeItem(i.InventoryItemId, i.CountedQuantity)).ToList()); + + var result = await _mediator.Send(command, ct); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error performing stocktake"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// EN: Delete an inventory item. + /// VI: Xóa nguyên liệu khỏi tồn kho. + /// + [HttpDelete("items/{id:guid}")] + [SwaggerOperation(Summary = "Delete an inventory item")] + [SwaggerResponse(200, "Item deleted successfully")] + [SwaggerResponse(404, "Item not found")] + public async Task>> DeleteItem( + Guid id, + CancellationToken ct = default) + { + try + { + var result = await _mediator.Send(new DeleteInventoryItemCommand(id), ct); + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found")); + return Ok(ApiResponse.Ok(true)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting inventory item"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + /// /// EN: Get transaction history by inventory item ID or shop ID. /// VI: Lấy lịch sử transactions theo inventory item ID hoặc shop ID. diff --git a/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj b/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj index d6601e31..450b4c47 100644 --- a/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj +++ b/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj @@ -26,10 +26,6 @@ - - - - diff --git a/services/inventory-service-net/src/InventoryService.API/Program.cs b/services/inventory-service-net/src/InventoryService.API/Program.cs index ae6178a0..d5acf7d5 100644 --- a/services/inventory-service-net/src/InventoryService.API/Program.cs +++ b/services/inventory-service-net/src/InventoryService.API/Program.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Asp.Versioning; using FluentValidation; using Hellang.Middleware.ProblemDetails; using InventoryService.API.Application.Behaviors; @@ -39,22 +38,6 @@ try // EN: Add FluentValidation / VI: Thêm FluentValidation builder.Services.AddValidatorsFromAssemblyContaining(); - // EN: Add API versioning / VI: Thêm API versioning - builder.Services.AddApiVersioning(options => - { - options.DefaultApiVersion = new ApiVersion(1, 0); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ReportApiVersions = true; - options.ApiVersionReader = ApiVersionReader.Combine( - new UrlSegmentApiVersionReader(), - new HeaderApiVersionReader("X-Api-Version")); - }) - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - }); - // EN: Add controllers / VI: Thêm controllers builder.Services.AddControllers(); diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs index 90e3abbe..af83506d 100644 --- a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs @@ -92,4 +92,10 @@ public interface IInventoryRepository : IRepository /// VI: Thêm inventory item bất đồng bộ. /// Task AddAsync(InventoryItem item, CancellationToken cancellationToken = default); + + /// + /// EN: Delete an inventory item. + /// VI: Xóa một inventory item. + /// + void Delete(InventoryItem item); } diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs index 70fd01c4..2356ae9a 100644 --- a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryItem.cs @@ -15,6 +15,12 @@ public class InventoryItem : Entity, IAggregateRoot { private Guid _productId; private Guid _shopId; + private string? _name; + private ItemType _itemType = null!; + private string _unit = "pcs"; + private decimal _costPerUnit; + private string? _supplierName; + private DateTime? _expiryDate; private int _quantity; private int _reservedQuantity; private int _reorderLevel; @@ -35,6 +41,48 @@ public class InventoryItem : Entity, IAggregateRoot /// public Guid ShopId => _shopId; + /// + /// EN: Item name (for raw materials not linked to catalog). + /// VI: Tên item (cho nguyên liệu thô không liên kết catalog). + /// + public string? Name => _name; + + /// + /// EN: Item type (RawMaterial, FinishedGood, Consumable). + /// VI: Loại item (Nguyên liệu thô, Thành phẩm, Vật tư tiêu hao). + /// + public ItemType ItemType => _itemType; + + /// + /// EN: Item type ID for EF Core mapping. + /// VI: Item type ID cho EF Core mapping. + /// + public int ItemTypeId { get; private set; } + + /// + /// EN: Unit of measure (e.g., "g", "ml", "pcs", "kg", "L"). + /// VI: Đơn vị tính (ví dụ: "g", "ml", "cái", "kg", "L"). + /// + public string Unit => _unit; + + /// + /// EN: Cost per unit (purchase price). + /// VI: Giá mỗi đơn vị (giá mua). + /// + public decimal CostPerUnit => _costPerUnit; + + /// + /// EN: Supplier name reference. + /// VI: Tên nhà cung cấp. + /// + public string? SupplierName => _supplierName; + + /// + /// EN: Expiry date for perishable items. + /// VI: Ngày hết hạn cho hàng dễ hỏng. + /// + public DateTime? ExpiryDate => _expiryDate; + /// /// EN: Total quantity in stock. /// VI: Tổng số lượng trong kho. @@ -86,8 +134,8 @@ public class InventoryItem : Entity, IAggregateRoot } /// - /// EN: Create a new inventory item. - /// VI: Tạo inventory item mới. + /// EN: Create a new inventory item (finished good linked to catalog product). + /// VI: Tạo inventory item mới (thành phẩm liên kết với sản phẩm catalog). /// public InventoryItem(Guid productId, Guid shopId, int reorderLevel = 10) { @@ -99,17 +147,87 @@ public class InventoryItem : Entity, IAggregateRoot Id = Guid.NewGuid(); _productId = productId; _shopId = shopId; + _itemType = ItemType.FinishedGood; + ItemTypeId = _itemType.Id; + _unit = "pcs"; + _costPerUnit = 0; _quantity = 0; _reservedQuantity = 0; _reorderLevel = reorderLevel; _createdAt = DateTime.UtcNow; } + /// + /// EN: Create a new inventory item with full details (for raw materials / consumables). + /// VI: Tạo inventory item mới với đầy đủ thông tin (cho nguyên liệu thô / vật tư tiêu hao). + /// + public InventoryItem( + Guid shopId, + string name, + ItemType itemType, + string unit, + decimal costPerUnit, + int initialQuantity = 0, + int reorderLevel = 10, + string? supplierName = null, + DateTime? expiryDate = null) + { + if (shopId == Guid.Empty) + throw new DomainException("Shop ID cannot be empty"); + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Name is required for raw material items"); + if (itemType == null) + throw new DomainException("Item type is required"); + if (string.IsNullOrWhiteSpace(unit)) + throw new DomainException("Unit is required"); + if (costPerUnit < 0) + throw new DomainException("Cost per unit cannot be negative"); + + Id = Guid.NewGuid(); + _productId = Guid.NewGuid(); // EN: Self-generated ID for non-catalog items / VI: ID tự sinh cho items không từ catalog + _shopId = shopId; + _name = name; + _itemType = itemType; + ItemTypeId = itemType.Id; + _unit = unit; + _costPerUnit = costPerUnit; + _supplierName = supplierName; + _expiryDate = expiryDate; + _quantity = initialQuantity; + _reservedQuantity = 0; + _reorderLevel = reorderLevel; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Update item details (name, unit, cost, supplier, expiry). + /// VI: Cập nhật thông tin item (tên, đơn vị, giá, nhà cung cấp, hạn dùng). + /// + public void UpdateItemDetails( + string? name = null, + string? unit = null, + decimal? costPerUnit = null, + string? supplierName = null, + DateTime? expiryDate = null) + { + if (name != null) _name = name; + if (unit != null) _unit = unit; + if (costPerUnit.HasValue) + { + if (costPerUnit.Value < 0) + throw new DomainException("Cost per unit cannot be negative"); + _costPerUnit = costPerUnit.Value; + } + _supplierName = supplierName ?? _supplierName; + _expiryDate = expiryDate ?? _expiryDate; + _updatedAt = DateTime.UtcNow; + } + /// /// EN: Stock in operation. /// VI: Thao tác nhập kho. /// - public void StockIn(int amount, string? notes = null, Guid? referenceId = null) + public void StockIn(int amount, string? notes = null, Guid? referenceId = null, string? invoiceImageUrl = null, decimal? unitCost = null) { if (amount <= 0) throw new DomainException("Stock in amount must be positive"); @@ -117,7 +235,7 @@ public class InventoryItem : Entity, IAggregateRoot _quantity += amount; _updatedAt = DateTime.UtcNow; - var transaction = new InventoryTransaction(Id, TransactionType.In, amount, referenceId, notes); + var transaction = new InventoryTransaction(Id, TransactionType.In, amount, referenceId, notes, invoiceImageUrl, unitCost); _transactions.Add(transaction); AddDomainEvent(new StockChangedDomainEvent(this)); @@ -194,4 +312,19 @@ public class InventoryItem : Entity, IAggregateRoot AddDomainEvent(new StockChangedDomainEvent(this)); } + + /// + /// EN: Record wastage/shrinkage (expired, damaged, spilled items). + /// VI: Ghi nhận hao hụt (hàng hết hạn, hư hỏng, đổ tràn). + /// + public void RecordWastage(int amount, string reason, string? notes = null) + { + if (amount <= 0) throw new DomainException("Wastage amount must be positive"); + if (string.IsNullOrWhiteSpace(reason)) throw new DomainException("Wastage reason is required"); + _quantity -= amount; + _updatedAt = DateTime.UtcNow; + var transaction = new InventoryTransaction(Id, TransactionType.Wastage, -amount, null, $"{reason}: {notes}"); + _transactions.Add(transaction); + AddDomainEvent(new StockChangedDomainEvent(this)); + } } diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryTransaction.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryTransaction.cs index 40263067..6e11d404 100644 --- a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryTransaction.cs +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/InventoryTransaction.cs @@ -16,6 +16,8 @@ public class InventoryTransaction : Entity private int _quantity; private Guid? _referenceId; // Order ID, PO ID, etc. private string? _notes; + private string? _invoiceImageUrl; + private decimal? _unitCost; private DateTime _createdAt; /// @@ -54,6 +56,18 @@ public class InventoryTransaction : Entity /// public string? Notes => _notes; + /// + /// EN: URL to uploaded invoice image (for stock-in). + /// VI: URL ảnh hóa đơn đã tải lên (cho nhập kho). + /// + public string? InvoiceImageUrl => _invoiceImageUrl; + + /// + /// EN: Cost per unit at time of stock-in. + /// VI: Giá mỗi đơn vị tại thời điểm nhập kho. + /// + public decimal? UnitCost => _unitCost; + /// /// EN: Creation timestamp. /// VI: Thời gian tạo. @@ -77,7 +91,9 @@ public class InventoryTransaction : Entity TransactionType type, int quantity, Guid? referenceId = null, - string? notes = null) + string? notes = null, + string? invoiceImageUrl = null, + decimal? unitCost = null) { Id = Guid.NewGuid(); _inventoryItemId = inventoryItemId; @@ -86,6 +102,8 @@ public class InventoryTransaction : Entity _quantity = quantity; _referenceId = referenceId; _notes = notes; + _invoiceImageUrl = invoiceImageUrl; + _unitCost = unitCost; _createdAt = DateTime.UtcNow; } } diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/ItemType.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/ItemType.cs new file mode 100644 index 00000000..e3858a93 --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/ItemType.cs @@ -0,0 +1,35 @@ +// EN: Item type enumeration for inventory items. +// VI: Enumeration loại item cho inventory items. + +using InventoryService.Domain.SeedWork; + +namespace InventoryService.Domain.AggregatesModel.InventoryAggregate; + +/// +/// EN: Item type enumeration - classifies inventory items. +/// VI: Enumeration loại item - phân loại inventory items. +/// +public class ItemType : Enumeration +{ + /// + /// EN: Raw material - ingredients for production (coffee beans, milk, sugar). + /// VI: Nguyên liệu thô - nguyên liệu sản xuất (cà phê hạt, sữa, đường). + /// + public static readonly ItemType RawMaterial = new(1, nameof(RawMaterial)); + + /// + /// EN: Finished good - ready-to-sell products (Cappuccino, Espresso). + /// VI: Thành phẩm - sản phẩm sẵn sàng bán (Cappuccino, Espresso). + /// + public static readonly ItemType FinishedGood = new(2, nameof(FinishedGood)); + + /// + /// EN: Consumable - operational supplies (cups, napkins, straws). + /// VI: Vật tư tiêu hao - vật tư vận hành (ly, khăn giấy, ống hút). + /// + public static readonly ItemType Consumable = new(3, nameof(Consumable)); + + public ItemType(int id, string name) : base(id, name) + { + } +} diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/TransactionType.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/TransactionType.cs index fc4005d1..affc690e 100644 --- a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/TransactionType.cs +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/TransactionType.cs @@ -41,6 +41,12 @@ public class TransactionType : Enumeration /// public static readonly TransactionType Release = new(5, nameof(Release)); + /// + /// EN: Wastage/shrinkage - damaged, expired, spilled items. + /// VI: Hao hụt - hàng hư hỏng, hết hạn, đổ tràn. + /// + public static readonly TransactionType Wastage = new(6, nameof(Wastage)); + public TransactionType(int id, string name) : base(id, name) { } diff --git a/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/InventoryItemEntityTypeConfiguration.cs b/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/InventoryItemEntityTypeConfiguration.cs index b6112703..59e0878d 100644 --- a/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/InventoryItemEntityTypeConfiguration.cs +++ b/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/InventoryItemEntityTypeConfiguration.cs @@ -18,12 +18,26 @@ public class InventoryItemEntityTypeConfiguration : IEntityTypeConfiguration i.ProductId).HasField("_productId").HasColumnName("product_id").IsRequired(); builder.Property(i => i.ShopId).HasField("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_name").HasColumnName("name").HasMaxLength(200); + builder.Property(i => i.ItemTypeId).HasColumnName("item_type_id").IsRequired().HasDefaultValue(2); + builder.Property("_unit").HasColumnName("unit").HasMaxLength(20).IsRequired().HasDefaultValue("pcs"); + builder.Property("_costPerUnit").HasColumnName("cost_per_unit").HasPrecision(18, 4).HasDefaultValue(0m); + builder.Property("_supplierName").HasColumnName("supplier_name").HasMaxLength(200); + builder.Property("_expiryDate").HasColumnName("expiry_date"); builder.Property(i => i.Quantity).HasField("_quantity").HasColumnName("quantity").IsRequired(); builder.Property(i => i.ReservedQuantity).HasField("_reservedQuantity").HasColumnName("reserved_quantity").IsRequired(); builder.Property(i => i.ReorderLevel).HasField("_reorderLevel").HasColumnName("reorder_level").HasDefaultValue(10); builder.Property(i => i.UpdatedAt).HasField("_updatedAt").HasColumnName("updated_at"); + // EN: Ignore computed/navigation properties not stored directly. + // VI: Bỏ qua các property tính toán/navigation không lưu trực tiếp. builder.Ignore(i => i.CreatedAt); + builder.Ignore(i => i.Name); + builder.Ignore(i => i.ItemType); + builder.Ignore(i => i.Unit); + builder.Ignore(i => i.CostPerUnit); + builder.Ignore(i => i.SupplierName); + builder.Ignore(i => i.ExpiryDate); // Owned InventoryTransaction collection builder.OwnsMany(i => i.Transactions, txn => @@ -37,12 +51,16 @@ public class InventoryItemEntityTypeConfiguration : IEntityTypeConfiguration("_quantity").HasColumnName("quantity").IsRequired(); txn.Property("_referenceId").HasColumnName("reference_id"); txn.Property("_notes").HasColumnName("notes").HasMaxLength(500); + txn.Property("_invoiceImageUrl").HasColumnName("invoice_image_url").HasMaxLength(1000); + txn.Property("_unitCost").HasColumnName("unit_cost").HasPrecision(18, 4); txn.Property("_createdAt").HasColumnName("created_at").IsRequired(); txn.Ignore(t => t.Type); txn.Ignore(t => t.Quantity); txn.Ignore(t => t.ReferenceId); txn.Ignore(t => t.Notes); + txn.Ignore(t => t.InvoiceImageUrl); + txn.Ignore(t => t.UnitCost); txn.Ignore(t => t.CreatedAt); }); diff --git a/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/ItemTypeEntityTypeConfiguration.cs b/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/ItemTypeEntityTypeConfiguration.cs new file mode 100644 index 00000000..7aa106fb --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/ItemTypeEntityTypeConfiguration.cs @@ -0,0 +1,26 @@ +// EN: ItemType enumeration configuration. +// VI: Cấu hình ItemType enumeration. + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using InventoryService.Domain.AggregatesModel.InventoryAggregate; + +namespace InventoryService.Infrastructure.EntityConfigurations; + +public class ItemTypeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("item_types"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedNever(); + builder.Property(t => t.Name).HasColumnName("name").HasMaxLength(50).IsRequired(); + + builder.HasData( + ItemType.RawMaterial, + ItemType.FinishedGood, + ItemType.Consumable + ); + } +} diff --git a/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/TransactionTypeEntityTypeConfiguration.cs b/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/TransactionTypeEntityTypeConfiguration.cs index ede20181..cd6bd86e 100644 --- a/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/TransactionTypeEntityTypeConfiguration.cs +++ b/services/inventory-service-net/src/InventoryService.Infrastructure/EntityConfigurations/TransactionTypeEntityTypeConfiguration.cs @@ -22,7 +22,8 @@ public class TransactionTypeEntityTypeConfiguration : IEntityTypeConfiguration(); + modelBuilder.Ignore(); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) diff --git a/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs b/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs index b31c1c9b..94678023 100644 --- a/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs +++ b/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs @@ -137,4 +137,9 @@ public class InventoryRepository : IInventoryRepository var entity = await _context.InventoryItems.AddAsync(item, cancellationToken); return entity.Entity; } + + public void Delete(InventoryItem item) + { + _context.InventoryItems.Remove(item); + } } 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 be8e9eec..f5767e71 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 @@ -28,7 +28,8 @@ public record OrderItemRequest( string ProductName, string ProductType, int Quantity, - decimal UnitPrice + decimal UnitPrice, + bool TrackInventory = true ); /// 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 00d6dbb8..8b4dbf71 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 @@ -48,7 +48,8 @@ public class CreateOrderCommandHandler : IRequestHandler _logger; public string SupportedType => "PreparedFood"; public FnbStrategy( FnbEngineClient fnbClient, + InventoryServiceClient inventoryClient, ILogger logger) { _fnbClient = fnbClient ?? throw new ArgumentNullException(nameof(fnbClient)); + _inventoryClient = inventoryClient ?? throw new ArgumentNullException(nameof(inventoryClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -69,5 +72,52 @@ public class FnbStrategy : ILineItemStrategy "EN: Kitchen ticket created / VI: Phiếu bếp đã tạo: {ProductName}, TicketId: {TicketId}", item.ProductName, ticketId); + + // EN: Skip inventory deduction if product doesn't track inventory + // VI: Bỏ qua trừ kho nếu sản phẩm không theo dõi tồn kho + if (item.TrackInventory) + { + // EN: Look up recipe and deduct raw materials from inventory + // VI: Tra cứu công thức và trừ nguyên liệu thô từ kho + var recipe = await _fnbClient.GetRecipeByProductAsync( + item.ProductId, shopId, cancellationToken); + + if (recipe?.Ingredients != null) + { + foreach (var ingredient in recipe.Ingredients) + { + if (ingredient.InventoryItemId == null || ingredient.InventoryItemId == Guid.Empty) + continue; + + // EN: Calculate total quantity needed = quantityPerServing * order quantity + // VI: Tính tổng lượng cần = lượng/phần * số lượng order + var deductQty = ingredient.QuantityPerServing > 0 + ? (int)Math.Ceiling(ingredient.QuantityPerServing * item.Quantity) + : item.Quantity; + + var deducted = await _inventoryClient.DeductStockByIdAsync( + ingredient.InventoryItemId.Value, deductQty, null, cancellationToken); + + if (!deducted) + { + _logger.LogWarning( + "EN: Failed to deduct inventory / VI: Trừ kho thất bại: Ingredient={Name}, ItemId={ItemId}, Qty={Qty}", + ingredient.IngredientName, ingredient.InventoryItemId, deductQty); + } + else + { + _logger.LogInformation( + "EN: Inventory deducted / VI: Đã trừ kho: {Name} x{Qty}", + ingredient.IngredientName, deductQty); + } + } + } + } + else + { + _logger.LogInformation( + "EN: Skipping inventory deduction (trackInventory=false) / VI: Bỏ qua trừ kho (trackInventory=false): {ProductName}", + item.ProductName); + } } } diff --git a/services/order-service-net/src/OrderService.API/Controllers/AdminOrdersController.cs b/services/order-service-net/src/OrderService.API/Controllers/AdminOrdersController.cs index 800d45f4..992e11bb 100644 --- a/services/order-service-net/src/OrderService.API/Controllers/AdminOrdersController.cs +++ b/services/order-service-net/src/OrderService.API/Controllers/AdminOrdersController.cs @@ -1,7 +1,6 @@ // EN: Admin Orders REST API Controller. // VI: Controller REST API cho Admin Orders. -using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Mvc; using OrderService.API.Application.DTOs; @@ -14,8 +13,7 @@ namespace OrderService.API.Controllers; /// VI: Controller API Admin Orders cho quản lý đơn hàng dạng admin. /// [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/admin/orders")] +[Route("api/v1/admin/orders")] public class AdminOrdersController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs index e79713a5..bfe4153c 100644 --- a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs +++ b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs @@ -1,7 +1,6 @@ // EN: Orders REST API Controller. // VI: Controller REST API cho Orders. -using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Mvc; using OrderService.API.Application.Commands; @@ -15,8 +14,7 @@ namespace OrderService.API.Controllers; /// VI: Controller API Orders cho quản lý đơn hàng. /// [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/orders")] +[Route("api/v1/orders")] public class OrdersController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs index 4c05bdf0..2c8ea8a4 100644 --- a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs +++ b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs @@ -1,7 +1,6 @@ // EN: Reports REST API Controller. // VI: Controller REST API cho Reports. -using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Mvc; using OrderService.API.Application.Queries; @@ -13,8 +12,7 @@ namespace OrderService.API.Controllers; /// VI: Controller API Reports cho phân tích doanh thu và sản phẩm. /// [ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/reports")] +[Route("api/v1/reports")] public class ReportsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/FnbEngineClient.cs b/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/FnbEngineClient.cs index df49f215..883d3443 100644 --- a/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/FnbEngineClient.cs +++ b/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/FnbEngineClient.cs @@ -65,6 +65,33 @@ public class FnbEngineClient return null; } } + + /// + /// EN: Get recipe by product ID and shop ID for inventory deduction. + /// VI: Lấy công thức theo product ID và shop ID để trừ kho. + /// + public async Task GetRecipeByProductAsync( + Guid productId, + Guid shopId, + CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync( + $"/api/v1/kitchen/recipes/by-product?productId={productId}&shopId={shopId}", + cancellationToken); + + if (!response.IsSuccessStatusCode) return null; + + var wrapper = await response.Content.ReadFromJsonAsync>(cancellationToken); + return wrapper?.Data; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "EN: Failed to get recipe / VI: Không lấy được công thức: Product={ProductId}", productId); + return null; + } + } } public record CreateKitchenTicketRequest( @@ -75,3 +102,13 @@ public record CreateKitchenTicketRequest( string? Notes); public record CreateKitchenTicketResult(Guid TicketId); + +public record RecipeWithIngredientsResult( + Guid Id, Guid ProductId, Guid ShopId, string Name, + List Ingredients); + +public record RecipeIngredientResult( + Guid Id, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit, + Guid? InventoryItemId, decimal QuantityPerServing); + +public record ApiResponseWrapper(bool Success, T? Data, string? Error); diff --git a/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/InventoryServiceClient.cs b/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/InventoryServiceClient.cs index 32c150a6..b8fdb84c 100644 --- a/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/InventoryServiceClient.cs +++ b/services/order-service-net/src/OrderService.API/Infrastructure/HttpClients/InventoryServiceClient.cs @@ -86,6 +86,36 @@ public class InventoryServiceClient return false; } } + /// + /// EN: Deduct stock by inventory item ID (for recipe ingredients). + /// VI: Trừ kho theo inventory item ID (cho nguyên liệu công thức). + /// + public async Task DeductStockByIdAsync( + Guid inventoryItemId, + int quantity, + Guid? orderId = null, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation( + "EN: Deducting stock by ID / VI: Trừ kho theo ID: ItemId={ItemId}, Quantity={Quantity}", + inventoryItemId, quantity); + + var request = new { inventoryItemId, amount = quantity, notes = "POS order deduction", referenceId = orderId }; + var response = await _httpClient.PostAsJsonAsync( + "/api/v1/inventory/stock-out-by-id", + request, + cancellationToken); + + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "EN: Error deducting stock by ID / VI: Lỗi trừ kho theo ID"); + return false; + } + } } public record StockCheckResult(bool IsAvailable, int AvailableQuantity); diff --git a/services/order-service-net/src/OrderService.API/OrderService.API.csproj b/services/order-service-net/src/OrderService.API/OrderService.API.csproj index c76483fd..34339408 100644 --- a/services/order-service-net/src/OrderService.API/OrderService.API.csproj +++ b/services/order-service-net/src/OrderService.API/OrderService.API.csproj @@ -25,10 +25,6 @@ - - - - diff --git a/services/order-service-net/src/OrderService.API/Program.cs b/services/order-service-net/src/OrderService.API/Program.cs index ec02f376..dbda4b63 100644 --- a/services/order-service-net/src/OrderService.API/Program.cs +++ b/services/order-service-net/src/OrderService.API/Program.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using System.Data; -using Asp.Versioning; using FluentValidation; using Hellang.Middleware.ProblemDetails; using Npgsql; @@ -102,22 +101,6 @@ try // EN: Add FluentValidation / VI: Thêm FluentValidation builder.Services.AddValidatorsFromAssemblyContaining(); - // EN: Add API versioning / VI: Thêm API versioning - builder.Services.AddApiVersioning(options => - { - options.DefaultApiVersion = new ApiVersion(1, 0); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ReportApiVersions = true; - options.ApiVersionReader = ApiVersionReader.Combine( - new UrlSegmentApiVersionReader(), - new HeaderApiVersionReader("X-Api-Version")); - }) - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - }); - // EN: Add controllers / VI: Thêm controllers builder.Services.AddControllers(); diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderItem.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderItem.cs index 789b9295..67cac9c4 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderItem.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderItem.cs @@ -18,6 +18,7 @@ public class OrderItem : Entity private int _quantity; private decimal _unitPrice; private string _status = null!; // Pending, Completed, Failed + private bool _trackInventory = true; // EN: Whether to auto-deduct inventory / VI: Có tự động trừ kho hay không private string? _metadata; // Additional data as JSON /// @@ -62,6 +63,12 @@ public class OrderItem : Entity /// public string Status => _status; + /// + /// EN: Whether this item should auto-deduct inventory when ordered. + /// VI: Có tự động trừ kho khi đặt hàng hay không. + /// + public bool TrackInventory => _trackInventory; + /// /// EN: Additional metadata as JSON. /// VI: Metadata bổ sung dưới dạng JSON. @@ -86,7 +93,8 @@ public class OrderItem : Entity string productType, int quantity, decimal unitPrice, - string? metadata = null) + string? metadata = null, + bool trackInventory = true) { if (productId == Guid.Empty) throw new DomainException("Product ID cannot be empty"); @@ -107,6 +115,7 @@ public class OrderItem : Entity _unitPrice = unitPrice; _status = "Pending"; _metadata = metadata; + _trackInventory = trackInventory; } /// 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 8bd9aabe..4ec71957 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs @@ -110,6 +110,11 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration .HasMaxLength(50) .IsRequired(); + orderItems.Property("_trackInventory") + .HasColumnName("track_inventory") + .HasDefaultValue(true) + .IsRequired(); + orderItems.Property("_metadata") .HasColumnName("metadata") .HasColumnType("jsonb"); @@ -121,6 +126,7 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration orderItems.Ignore(x => x.UnitPrice); orderItems.Ignore(x => x.TotalPrice); orderItems.Ignore(x => x.Status); + orderItems.Ignore(x => x.TrackInventory); orderItems.Ignore(x => x.Metadata); });