@@ -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;
-
- { var t = _recipeIngredients[i]; _recipeIngredients[i] = (e.Value?.ToString() ?? "", t.Unit, t.Qty, t.Quantity, t.Cost); })" placeholder="Tên nguyên liệu" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
- { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, t.Unit, t.Qty, decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0, t.Cost); })" type="number" placeholder="Qty" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
- { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, e.Value?.ToString() ?? "", t.Qty, t.Quantity, t.Cost); })" placeholder="Đơn vị" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
- { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, t.Unit, t.Qty, t.Quantity, decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0); })" type="number" placeholder="Chi phí" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
-
+
+ @* EN: Inventory item dropdown — select raw material from inventory / VI: Dropdown chọn nguyên liệu từ kho *@
+
+ { _recipeIngredients[i].Name = e.Value?.ToString() ?? ""; })" placeholder="Tên nguyên liệu" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
+ { _recipeIngredients[i].Quantity = decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0; })" type="number" step="0.01" placeholder="Qty" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
+ { _recipeIngredients[i].Unit = e.Value?.ToString() ?? ""; })" placeholder="Đơn vị" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
+ { _recipeIngredients[i].Cost = decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0; })" type="number" placeholder="Chi phí" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
+
+ @if (_recipeIngredients[i].InventoryItemId != null)
+ {
+
+ Qty/serving: { _recipeIngredients[i].QuantityPerServing = decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0; })" type="number" step="0.01" placeholder="Qty per serving" style="width:80px;padding:3px 6px;border-radius:4px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:11px;" />
+
+ }
}
@@ -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);
});
|