feat: enhance inventory management with new item types, stocktake, wastage, and recipe-based deductions

This commit is contained in:
Ho Ngoc Hai
2026-03-05 22:28:45 +07:00
parent 6d5d4108c7
commit fd75da34dc
49 changed files with 1509 additions and 146 deletions

View File

@@ -22,9 +22,78 @@
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);"><i data-lucide="x-circle" style="color:#EF4444;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity <= 0)</span><span class="admin-stat-card__label">Hết hàng</span></div></div>
</div>
<!-- EN: Action bar — add new inventory item button / VI: Thanh hành động — nút thêm nguyên liệu mới -->
<div style="display:flex;justify-content:flex-end;margin-top:16px;">
<button @onclick="@(() => { _showCreateForm = !_showCreateForm; })"
style="padding:8px 16px;background:var(--admin-orange-primary);color:#FFF;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="plus" style="width:14px;height:14px;"></i> Thêm nguyên liệu
</button>
</div>
@if (_showCreateForm)
{
<!-- EN: Create new inventory item form / VI: Form tạo mặt hàng tồn kho mới -->
<div class="admin-panel" style="margin-top:12px;">
<div class="admin-panel__header">
<h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="package-plus" style="width:18px;height:18px;margin-right:8px;color:var(--admin-orange-primary);"></i>Thêm nguyên liệu / vật tư</h3>
<button @onclick="@(() => _showCreateForm = false)" style="background:transparent;border:none;cursor:pointer;color:var(--admin-text-tertiary);font-size:18px;">✕</button>
</div>
<div class="admin-panel__body" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:700px;">
<div style="grid-column:span 2;">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Tên nguyên liệu *</label>
<input type="text" @bind="_createName" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="VD: Cà phê rang xay, Sữa tươi..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Loại *</label>
<select @bind="_createItemTypeId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="1">Nguyên liệu</option>
<option value="2">Thành phẩm</option>
<option value="3">Vật tư tiêu hao</option>
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Đơn vị *</label>
<select @bind="_createUnit" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="g">g (gram)</option>
<option value="ml">ml (mililít)</option>
<option value="kg">kg (kilogram)</option>
<option value="L">L (lít)</option>
<option value="cái">cái</option>
<option value="gói">gói</option>
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Giá nhập / đơn vị (VND) *</label>
<input type="number" @bind="_createCostPerUnit" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="0" />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng ban đầu</label>
<input type="number" @bind="_createInitialQty" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="0" />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Mức nhập lại</label>
<input type="number" @bind="_createReorderLevel" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="10" />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Nhà cung cấp</label>
<input type="text" @bind="_createSupplier" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Tên nhà cung cấp..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Hạn sử dụng</label>
<input type="date" @bind="_createExpiryDate" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" />
</div>
<div style="grid-column:span 2;">
<button @onclick="DoCreateInventoryItem" style="padding:10px 24px;background:var(--admin-orange-primary);color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:6px;"></i>Tạo nguyên liệu
</button>
</div>
</div>
</div>
}
<!-- Sub-tabs -->
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;margin-top:16px;">
@foreach (var (label, val, icon) in new[] { ("Tồn kho", "levels", "package"), ("Nhập kho", "stock-in", "arrow-down-to-line"), ("Xuất kho", "stock-out", "arrow-up-from-line"), ("Điều chỉnh", "adjust", "settings-2"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") })
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;margin-top:16px;flex-wrap:wrap;">
@foreach (var (label, val, icon) in new[] { ("Tồn kho", "levels", "package"), ("Nhập kho", "stock-in", "arrow-down-to-line"), ("Xuất kho", "stock-out", "arrow-up-from-line"), ("Điều chỉnh", "adjust", "settings-2"), ("Hao hụt", "wastage", "trash-2"), ("Kiểm kê", "stocktake", "clipboard-check"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") })
{
<button @onclick="@(() => SwitchInvSubTab(val))"
style="padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;display:flex;align-items:center;gap:6px;
@@ -45,10 +114,12 @@
{
case "levels":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__body" style="padding:0;">
<div class="admin-panel__body" style="padding:0;overflow-x:auto;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Giá nhập</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mức nhập lại</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thao tác</th>
</tr></thead><tbody>
@@ -56,9 +127,23 @@
{
var qtyColor = item.Quantity <= 0 ? "#EF4444" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "#F59E0B" : "#22C55E";
var bgColor = item.Quantity <= 0 ? "rgba(239,68,68,0.05)" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "rgba(245,158,11,0.05)" : "transparent";
// EN: ItemType badge colors — RawMaterial=green, FinishedProduct=blue, Supply=gray
// VI: Màu badge loại — Nguyên liệu=xanh lá, Thành phẩm=xanh dương, Vật tư=xám
var (typeBadge, typeBg, typeColor) = item.ItemType switch
{
"RawMaterial" => ("Nguyên liệu", "rgba(34,197,94,0.1)", "#16A34A"),
"FinishedProduct" => ("Thành phẩm", "rgba(59,130,246,0.1)", "#3B82F6"),
"Supply" => ("Vật tư", "rgba(107,114,128,0.1)", "#6B7280"),
_ => ("—", "transparent", "var(--admin-text-tertiary)")
};
var unitLabel = item.Unit ?? "";
<tr style="border-top:1px solid var(--admin-border-subtle);background:@bgColor;">
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8])</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@qtyColor;">@item.Quantity</td>
<td style="padding:12px 16px;text-align:center;">
<span style="padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:@typeBg;color:@typeColor;">@typeBadge</span>
</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@qtyColor;">@item.Quantity @unitLabel</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-secondary);">@(item.CostPerUnit.HasValue ? item.CostPerUnit.Value.ToString("#,0") + "đ" : "—")</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.ReorderLevel</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
@@ -66,6 +151,9 @@
style="background:rgba(34,197,94,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#16A34A;cursor:pointer;">+Nhập</button>
<button @onclick="@(() => { _invSubTab = "stock-out"; _invSelectedProductId = item.ProductId; _invAmount = 0; _invNotes = ""; StateHasChanged(); })"
style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#DC2626;cursor:pointer;">-Xuất</button>
<button @onclick="@(() => DoDeleteInventoryItem(item.Id))"
style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#DC2626;cursor:pointer;"
title="Xóa nguyên liệu">🗑</button>
</div>
</td>
</tr>
@@ -78,14 +166,14 @@
case "stock-in":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="arrow-down-to-line" style="width:18px;height:18px;margin-right:8px;color:#22C55E;"></i>Nhập kho</h3></div>
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
<div>
<div class="admin-panel__body" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:700px;">
<div style="grid-column:span 2;">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
@foreach (var item in _inventory)
{
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity @(item.Unit ?? ""))</option>
}
</select>
</div>
@@ -93,13 +181,32 @@
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng nhập *</label>
<input type="number" @bind="_invAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Nhập số lượng..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Giá nhập / đơn vị (VND)</label>
<input type="number" @bind="_invStockInUnitCost" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Giá nhập lần này..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Nhà cung cấp</label>
<input type="text" @bind="_invStockInSupplier" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Tên nhà cung cấp..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Lý do nhập kho..." />
</div>
<button @onclick="DoStockIn" style="padding:10px 20px;background:#22C55E;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="arrow-down-to-line" style="width:16px;height:16px;margin-right:6px;"></i>Nhập kho
</button>
<!-- EN: Invoice image upload placeholder / VI: Placeholder tải hóa đơn -->
<div style="grid-column:span 2;">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Tải hóa đơn</label>
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:24px;text-align:center;cursor:pointer;background:var(--admin-bg-elevated);">
<i data-lucide="upload" style="width:28px;height:28px;color:var(--admin-text-tertiary);margin-bottom:8px;"></i>
<p style="margin:0;font-size:13px;color:var(--admin-text-tertiary);">Kéo thả hoặc click để tải ảnh hóa đơn</p>
<p style="margin:4px 0 0;font-size:11px;color:var(--admin-text-tertiary);opacity:0.6;">PNG, JPG — Tối đa 5MB (sắp ra mắt)</p>
</div>
</div>
<div style="grid-column:span 2;">
<button @onclick="DoStockIn" style="padding:10px 20px;background:#22C55E;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="arrow-down-to-line" style="width:16px;height:16px;margin-right:6px;"></i>Nhập kho
</button>
</div>
</div>
</div>
break;
@@ -162,6 +269,112 @@
</div>
break;
case "wastage":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="trash-2" style="width:18px;height:18px;margin-right:8px;color:#EF4444;"></i>Ghi nhận hao hụt</h3></div>
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Nguyên liệu *</label>
<select @bind="_wastageSelectedItemId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn nguyên liệu --</option>
@foreach (var item in _inventory)
{
<option value="@item.Id">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity @(item.Unit ?? ""))</option>
}
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng hao hụt *</label>
<input type="number" @bind="_wastageAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Nhập số lượng..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Lý do *</label>
<select @bind="_wastageReason" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="">-- Chọn lý do --</option>
<option value="Hết hạn">Hết hạn</option>
<option value="Hư hỏng">Hư hỏng</option>
<option value="Đổ/tràn">Đổ/tràn</option>
<option value="Khác">Khác</option>
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
<input type="text" @bind="_wastageNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Chi tiết thêm..." />
</div>
<button @onclick="DoRecordWastage" style="padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="trash-2" style="width:16px;height:16px;margin-right:6px;"></i>Ghi nhận hao hụt
</button>
</div>
</div>
break;
case "stocktake":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="clipboard-check" style="width:18px;height:18px;margin-right:8px;color:#8B5CF6;"></i>Kiểm kê tồn kho</h3></div>
<div class="admin-panel__body" style="padding:0;overflow-x:auto;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nguyên liệu</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tồn hệ thống</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Đếm thực tế</th>
</tr></thead><tbody>
@foreach (var item in _inventory)
{
var itemId = item.Id;
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8]) <span style="font-size:11px;color:var(--admin-text-tertiary);">@(item.Unit ?? "")</span></td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:var(--admin-text-secondary);">@item.Quantity</td>
<td style="padding:12px 16px;text-align:right;">
<input type="number" min="0" value="@(GetStocktakeCount(itemId))"
@onchange="@(e => SetStocktakeCount(itemId, int.TryParse(e.Value?.ToString(), out var v) ? v : 0))"
style="width:100px;padding:6px 10px;border:1px solid var(--admin-border-subtle);border-radius:6px;font-size:14px;text-align:right;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" />
</td>
</tr>
}
</tbody></table>
<div style="padding:16px;display:flex;gap:12px;align-items:center;">
<button @onclick="DoStocktake" style="padding:10px 20px;background:#8B5CF6;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="clipboard-check" style="width:16px;height:16px;margin-right:6px;"></i>Xác nhận kiểm kê
</button>
</div>
</div>
</div>
@if (_stocktakeResult != null)
{
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="bar-chart-3" style="width:18px;height:18px;margin-right:8px;color:#F59E0B;"></i>Kết quả kiểm kê (@_stocktakeResult.TotalItemsCounted mặt hàng)</h3></div>
<div class="admin-panel__body" style="padding:0;">
@if (_stocktakeResult.Discrepancies.Any())
{
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nguyên liệu</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Hệ thống</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thực tế</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Chênh lệch</th>
</tr></thead><tbody>
@foreach (var d in _stocktakeResult.Discrepancies)
{
var diffColor = d.Difference > 0 ? "#22C55E" : "#EF4444";
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(d.ItemName ?? d.InventoryItemId.ToString()[..8])</td>
<td style="padding:12px 16px;text-align:right;color:var(--admin-text-secondary);">@d.ExpectedQuantity</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;">@d.CountedQuantity</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@diffColor;">@(d.Difference > 0 ? "+" : "")@d.Difference</td>
</tr>
}
</tbody></table>
}
else
{
<div style="text-align:center;padding:32px;color:var(--admin-text-tertiary);font-size:14px;">
<i data-lucide="check-circle" style="width:32px;height:32px;color:#22C55E;margin-bottom:8px;"></i><br/>
Không có chênh lệch. Tồn kho chính xác!
</div>
}
</div>
</div>
}
break;
case "transactions":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="history" style="width:18px;height:18px;margin-right:8px;color:#8B5CF6;"></i>Lịch sử giao dịch kho</h3></div>
@@ -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" };
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@tx.CreatedAt.ToLocalTime().ToString("dd/MM HH:mm")</td>
<td style="padding:12px 16px;">
@@ -260,6 +473,31 @@
private bool _invFormSuccess;
private List<PosDataService.LowStockItemInfo> _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<Guid, int> _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);
}
}
}

View File

@@ -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 @@
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hướng dẫn</label><textarea @bind="_newRecipeInstructions" rows="2" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);resize:vertical;"></textarea></div>
</div>
<div style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">Nguyên liệu</span><button @onclick='() => _recipeIngredients.Add(new("","","",0,0))' style="padding:4px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-primary);font-size:12px;cursor:pointer;">+ Thêm</button></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">Nguyên liệu / Ingredients</span><button @onclick="() => _recipeIngredients.Add(new IngredientRow())" style="padding:4px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-primary);font-size:12px;cursor:pointer;">+ Thêm</button></div>
@for (var idx = 0; idx < _recipeIngredients.Count; idx++)
{
var i = idx;
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto;gap:8px;margin-bottom:6px;">
<input value="@_recipeIngredients[i].Name" @onchange="@(e => { 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;" />
<input value="@_recipeIngredients[i].Quantity" @onchange="@(e => { 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;" />
<input value="@_recipeIngredients[i].Unit" @onchange="@(e => { 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;" />
<input value="@_recipeIngredients[i].Cost" @onchange="@(e => { 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;" />
<button @onclick='() => _recipeIngredients.RemoveAt(i)' style="padding:6px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;">✕</button>
<div style="display:grid;grid-template-columns:2fr 2fr 1fr 1fr 1fr auto;gap:8px;margin-bottom:6px;">
@* EN: Inventory item dropdown — select raw material from inventory / VI: Dropdown chọn nguyên liệu từ kho *@
<select value="@(_recipeIngredients[i].InventoryItemId?.ToString() ?? "")" @onchange="@(e => OnInventoryItemSelected(i, e.Value?.ToString()))" 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;">
<option value="">-- Chọn kho / Select inventory --</option>
@foreach (var item in _inventoryItems.Where(x => x.ItemType == "RawMaterial" || x.ItemType == null))
{
<option value="@item.Id">@(item.ProductName ?? item.Id.ToString()[..8]) (@item.Unit - @(item.CostPerUnit?.ToString("N0") ?? "0")d)</option>
}
</select>
<input value="@_recipeIngredients[i].Name" @onchange="@(e => { _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;" />
<input value="@_recipeIngredients[i].Quantity" @onchange="@(e => { _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;" />
<input value="@_recipeIngredients[i].Unit" @onchange="@(e => { _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;" />
<input value="@_recipeIngredients[i].Cost" @onchange="@(e => { _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;" />
<button @onclick="() => _recipeIngredients.RemoveAt(i)" style="padding:6px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;">X</button>
</div>
@if (_recipeIngredients[i].InventoryItemId != null)
{
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-bottom:6px;margin-left:4px;">
Qty/serving: <input value="@_recipeIngredients[i].QuantityPerServing" @onchange="@(e => { _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;" />
</div>
}
}
</div>
<div style="display:flex;gap:8px;">
@@ -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<PosDataService.RecipeInfo> _recipes = new();
private List<PosDataService.InventoryItemInfo> _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<IngredientRow> _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;

View File

@@ -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<List<InventoryItemInfo>> GetInventoryAsync(Guid? shopId = null)
{
@@ -231,6 +240,22 @@ public class PosDataService
return await GetListFromApiAsync<InventoryItemInfo>(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<bool> CreateInventoryItemAsync(CreateInventoryItemRequest req)
{
AttachToken();
var r = await _http.PostAsJsonAsync("api/bff/inventory/items", req, _writeOptions);
return r.IsSuccessStatusCode;
}
public async Task<bool> 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<bool> StockInAsync(StockInRequest req)
{
@@ -447,6 +475,38 @@ public class PosDataService
return await GetListFromApiAsync<LowStockItemInfo>(url);
}
// ═══ WASTAGE & STOCKTAKE ═══
public record RecordWastageRequest(Guid InventoryItemId, int Amount, string Reason, string? Notes);
public async Task<bool> 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<StocktakeItemRequest> Items);
public record StocktakeDiscrepancy(Guid InventoryItemId, string? ItemName, int ExpectedQuantity, int CountedQuantity, int Difference);
public record StocktakeResult(List<StocktakeDiscrepancy> Discrepancies, int TotalItemsCounted);
public async Task<StocktakeResult?> 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<StocktakeResult>(data.GetRawText(), _jsonOptions);
return JsonSerializer.Deserialize<StocktakeResult>(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<RecipeIngredientRequest>? 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<List<RecipeInfo>> GetRecipesAsync(Guid? shopId = null)
{

View File

@@ -89,13 +89,24 @@ public class InventoryController : ControllerBase
var obj = new Dictionary<string, object?>();
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 } });
}
/// <summary>
/// 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ư).
/// </summary>
[HttpPost("inventory/items")]
public Task<IActionResult> CreateInventoryItem([FromBody] JsonElement body) =>
_inventory.PostAsJsonAsync("/api/v1/inventory/items", body).ProxyAsync();
/// <summary>
/// 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<IActionResult> AdjustStock([FromBody] JsonElement body) =>
_inventory.PostAsJsonAsync("/api/v1/inventory/adjust", body).ProxyAsync();
/// <summary>
/// EN: Record wastage/shrinkage.
/// VI: Ghi nhận hao hụt.
/// </summary>
[HttpPost("inventory/wastage")]
public Task<IActionResult> RecordWastage([FromBody] JsonElement body) =>
_inventory.PostAsJsonAsync("/api/v1/inventory/wastage", body).ProxyAsync();
/// <summary>
/// EN: Perform stocktake (inventory count).
/// VI: Thực hiện kiểm kê (đếm tồn kho).
/// </summary>
[HttpPost("inventory/stocktake")]
public Task<IActionResult> Stocktake([FromBody] JsonElement body) =>
_inventory.PostAsJsonAsync("/api/v1/inventory/stocktake", body).ProxyAsync();
/// <summary>
/// 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();
}
/// <summary>
/// EN: Delete an inventory item.
/// VI: Xóa nguyên liệu khỏi tồn kho.
/// </summary>
[HttpDelete("inventory/items/{id:guid}")]
public Task<IActionResult> DeleteInventoryItem(Guid id) =>
_inventory.DeleteAsync($"/api/v1/inventory/items/{id}").ProxyAsync();
}

View File

@@ -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();

View File

@@ -14,7 +14,7 @@ public class CreateRecipeCommandHandler : IRequestHandler<CreateRecipeCommand, G
if (req.Ingredients != null)
{
foreach (var ing in req.Ingredients)
recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit);
recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit, ing.InventoryItemId, ing.QuantityPerServing);
}
_repo.Add(recipe);
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
@@ -32,12 +32,12 @@ public class UpdateRecipeCommandHandler : IRequestHandler<UpdateRecipeCommand, b
var recipe = await _repo.GetByIdAsync(req.RecipeId, ct);
if (recipe == null) return false;
recipe.Update(req.Name, req.Instructions, req.PrepTimeMinutes);
recipe.Update(req.ProductId, req.Name, req.Instructions, req.PrepTimeMinutes);
recipe.ClearIngredients();
if (req.Ingredients != null)
{
foreach (var ing in req.Ingredients)
recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit);
recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit, ing.InventoryItemId, ing.QuantityPerServing);
}
_repo.Update(recipe);
await _repo.UnitOfWork.SaveEntitiesAsync(ct);

View File

@@ -25,4 +25,7 @@ public record UpdateRecipeCommand : IRequest<bool>
public record DeleteRecipeCommand(Guid RecipeId) : IRequest<bool>;
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);

View File

@@ -4,11 +4,15 @@ using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
namespace FnbEngine.API.Application.Queries;
public record GetRecipesByShopQuery(Guid ShopId) : IRequest<IEnumerable<RecipeDto>>;
public record GetRecipeByProductQuery(Guid ProductId, Guid ShopId) : IRequest<RecipeDto?>;
public record RecipeDto(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions,
int PrepTimeMinutes, bool IsActive, DateTime CreatedAt, List<RecipeIngredientDto> 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<GetRecipesByShopQuery, IEnumerable<RecipeDto>>
{
@@ -20,7 +24,23 @@ public class GetRecipesByShopQueryHandler : IRequestHandler<GetRecipesByShopQuer
var recipes = await _repo.GetByShopIdAsync(req.ShopId, ct);
return recipes.Select(r => new RecipeDto(
r.Id, r.ProductId, r.ShopId, r.Name, r.Instructions, r.PrepTimeMinutes, r.IsActive, r.CreatedAt,
r.Ingredients.Select(i => new RecipeIngredientDto(i.Id, i.IngredientName, i.Quantity, i.Unit, i.CostPerUnit)).ToList()
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<GetRecipeByProductQuery, RecipeDto?>
{
private readonly IRecipeRepository _repo;
public GetRecipeByProductQueryHandler(IRecipeRepository repo) => _repo = repo;
public async Task<RecipeDto?> 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()
);
}
}

View File

@@ -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<IEnumerable<RecipeDto>> { Success = true, Data = result });
}
/// <summary>
/// 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).
/// </summary>
[HttpGet("recipes/by-product")]
[ProducesResponseType(typeof(ApiResponse<RecipeDto>), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<ApiResponse<RecipeDto>>> 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<RecipeDto> { Success = false, Error = "No recipe found for this product" });
return Ok(new ApiResponse<RecipeDto> { Success = true, Data = result });
}
[HttpPost("recipes")]
[ProducesResponseType(typeof(ApiResponse<Guid>), 200)]
public async Task<ActionResult<ApiResponse<Guid>>> CreateRecipe(

View File

@@ -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;

View File

@@ -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.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/sessions")]
[Route("api/v1/sessions")]
public class SessionsController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -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.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/tables")]
[Route("api/v1/tables")]
public class TablesController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -25,10 +25,6 @@
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<!-- EN: API Versioning / VI: API Versioning -->
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<!-- EN: Health checks / VI: Health checks -->
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />

View File

@@ -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<Program>();
// 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();

View File

@@ -9,4 +9,5 @@ public interface IRecipeRepository : IRepository<Recipe>
void Delete(Recipe recipe);
Task<Recipe?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IEnumerable<Recipe>> GetByShopIdAsync(Guid shopId, CancellationToken ct = default);
Task<Recipe?> GetByProductIdAndShopAsync(Guid productId, Guid shopId, CancellationToken ct = default);
}

View File

@@ -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()

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -23,4 +23,8 @@ public class RecipeRepository : IRecipeRepository
.Where(r => r.ShopId == shopId && r.IsActive)
.OrderBy(r => r.Name)
.ToListAsync(ct);
public async Task<Recipe?> 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);
}

View File

@@ -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;
/// <summary>
/// EN: Handler for CreateInventoryItemCommand.
/// VI: Handler cho CreateInventoryItemCommand.
/// </summary>
public class CreateInventoryItemCommandHandler : IRequestHandler<CreateInventoryItemCommand, Guid>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<CreateInventoryItemCommandHandler> _logger;
public CreateInventoryItemCommandHandler(
IInventoryRepository repository,
ILogger<CreateInventoryItemCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Guid> Handle(CreateInventoryItemCommand request, CancellationToken ct)
{
// EN: Resolve ItemType from ID
// VI: Xác định ItemType từ ID
var itemType = Enumeration.FromValue<ItemType>(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;
}
}
/// <summary>
/// EN: Handler for StockInCommand.
/// VI: Handler cho StockInCommand.
@@ -29,8 +75,8 @@ public class StockInCommandHandler : IRequestHandler<StockInCommand, Guid>
// 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<StockInCommand, Guid>
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<AdjustStockCommand, boo
return true;
}
}
/// <summary>
/// EN: Handler for StockOutByIdCommand - deducts by inventory item ID directly.
/// VI: Handler cho StockOutByIdCommand - trừ kho theo inventory item ID trực tiếp.
/// </summary>
public class StockOutByIdCommandHandler : IRequestHandler<StockOutByIdCommand, bool>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<StockOutByIdCommandHandler> _logger;
public StockOutByIdCommandHandler(
IInventoryRepository repository,
ILogger<StockOutByIdCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for RecordWastageCommand.
/// VI: Handler cho RecordWastageCommand.
/// </summary>
public class RecordWastageCommandHandler : IRequestHandler<RecordWastageCommand, bool>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<RecordWastageCommandHandler> _logger;
public RecordWastageCommandHandler(
IInventoryRepository repository,
ILogger<RecordWastageCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for StocktakeCommand.
/// VI: Handler cho StocktakeCommand.
/// </summary>
public class StocktakeCommandHandler : IRequestHandler<StocktakeCommand, StocktakeResult>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<StocktakeCommandHandler> _logger;
public StocktakeCommandHandler(
IInventoryRepository repository,
ILogger<StocktakeCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<StocktakeResult> Handle(StocktakeCommand request, CancellationToken ct)
{
var discrepancies = new List<StocktakeDiscrepancy>();
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);
}
}
/// <summary>
/// EN: Handler for DeleteInventoryItemCommand.
/// VI: Handler cho DeleteInventoryItemCommand.
/// </summary>
public class DeleteInventoryItemCommandHandler : IRequestHandler<DeleteInventoryItemCommand, bool>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<DeleteInventoryItemCommandHandler> _logger;
public DeleteInventoryItemCommandHandler(
IInventoryRepository repository,
ILogger<DeleteInventoryItemCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -5,6 +5,21 @@ using MediatR;
namespace InventoryService.API.Application.Commands;
/// <summary>
/// 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).
/// </summary>
public record CreateInventoryItemCommand(
Guid ShopId,
string Name,
int ItemTypeId,
string Unit,
decimal CostPerUnit,
int InitialQuantity,
int ReorderLevel,
string? SupplierName,
DateTime? ExpiryDate) : IRequest<Guid>;
/// <summary>
/// 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>;
Guid? ReferenceId,
string? InvoiceImageUrl,
decimal? UnitCost) : IRequest<Guid>;
/// <summary>
/// EN: Command to perform stock out operation.
@@ -56,3 +73,37 @@ public record AdjustStockCommand(
Guid ShopId,
int NewQuantity,
string Notes) : IRequest<bool>;
/// <summary>
/// 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).
/// </summary>
public record StockOutByIdCommand(
Guid InventoryItemId,
int Amount,
string? Notes,
Guid? ReferenceId) : IRequest<bool>;
/// <summary>
/// EN: Command to record wastage/shrinkage.
/// VI: Command để ghi nhận hao hụt.
/// </summary>
public record RecordWastageCommand(Guid InventoryItemId, int Amount, string Reason, string? Notes) : IRequest<bool>;
/// <summary>
/// EN: Command to perform stocktake (inventory count).
/// VI: Command để thực hiện kiểm kê (đếm tồn kho).
/// </summary>
public record StocktakeCommand(Guid ShopId, List<StocktakeItem> Items) : IRequest<StocktakeResult>;
public record StocktakeItem(Guid InventoryItemId, int CountedQuantity);
public record StocktakeResult(List<StocktakeDiscrepancy> Discrepancies, int TotalItemsCounted);
public record StocktakeDiscrepancy(Guid InventoryItemId, string? ItemName, int ExpectedQuantity, int CountedQuantity, int Difference);
/// <summary>
/// EN: Command to delete an inventory item.
/// VI: Command để xóa một inventory item.
/// </summary>
public record DeleteInventoryItemCommand(Guid InventoryItemId) : IRequest<bool>;

View File

@@ -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);
/// <summary>
@@ -39,7 +48,9 @@ public record StockInRequest(
Guid ShopId,
int Amount,
string? Notes,
Guid? ReferenceId);
Guid? ReferenceId,
string? InvoiceImageUrl,
decimal? UnitCost);
/// <summary>
/// EN: Request for stock out operation.
@@ -82,10 +93,37 @@ public record AdjustStockRequest(
int NewQuantity,
string Notes);
/// <summary>
/// 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).
/// </summary>
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);
/// <summary>
/// EN: Standard API response wrapper.
/// VI: Wrapper response API chuẩn.
/// </summary>
public record RecordWastageRequest(Guid InventoryItemId, int Amount, string Reason, string? Notes);
public record StocktakeRequest(Guid ShopId, List<StocktakeItemRequest> Items);
public record StocktakeItemRequest(Guid InventoryItemId, int CountedQuantity);
public class ApiResponse<T>
{
public bool Success { get; set; }

View File

@@ -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"
};
}

View File

@@ -6,6 +6,48 @@ using InventoryService.API.Application.Commands;
namespace InventoryService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateInventoryItemCommand.
/// VI: Validator cho CreateInventoryItemCommand.
/// </summary>
public class CreateInventoryItemCommandValidator : AbstractValidator<CreateInventoryItemCommand>
{
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");
}
}
/// <summary>
/// EN: Validator for StockInCommand.
/// VI: Validator cho StockInCommand.

View File

@@ -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.
/// </summary>
[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<InventoryItemDto>.Ok(result));
}
/// <summary>
/// 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.).
/// </summary>
[HttpPost("items")]
[SwaggerOperation(Summary = "Create a new inventory item")]
[SwaggerResponse(201, "Item created successfully")]
[SwaggerResponse(400, "Invalid request")]
public async Task<ActionResult<ApiResponse<Guid>>> 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<Guid>.Ok(itemId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating inventory item");
return BadRequest(ApiResponse<Guid>.Fail(ex.Message));
}
}
/// <summary>
/// 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
}
}
/// <summary>
/// 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).
/// </summary>
[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<ActionResult<ApiResponse<bool>>> 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<bool>.Fail("Inventory item not found"));
return Ok(ApiResponse<bool>.Ok(result));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error performing stock out by ID");
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Reserve stock for order.
/// VI: Đặt trước stock cho order.
@@ -250,6 +319,96 @@ public class InventoryController : ControllerBase
}
}
/// <summary>
/// EN: Record wastage/shrinkage.
/// VI: Ghi nhận hao hụt.
/// </summary>
[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<ActionResult<ApiResponse<bool>>> 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<bool>.Fail("Inventory item not found"));
return Ok(ApiResponse<bool>.Ok(result));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording wastage");
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Perform stocktake (inventory count).
/// VI: Thực hiện kiểm kê (đếm tồn kho).
/// </summary>
[HttpPost("stocktake")]
[SwaggerOperation(Summary = "Perform stocktake and return discrepancies")]
[SwaggerResponse(200, "Stocktake completed successfully")]
[SwaggerResponse(400, "Invalid request")]
public async Task<ActionResult<ApiResponse<StocktakeResult>>> 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<StocktakeResult>.Ok(result));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error performing stocktake");
return BadRequest(ApiResponse<StocktakeResult>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Delete an inventory item.
/// VI: Xóa nguyên liệu khỏi tồn kho.
/// </summary>
[HttpDelete("items/{id:guid}")]
[SwaggerOperation(Summary = "Delete an inventory item")]
[SwaggerResponse(200, "Item deleted successfully")]
[SwaggerResponse(404, "Item not found")]
public async Task<ActionResult<ApiResponse<bool>>> DeleteItem(
Guid id,
CancellationToken ct = default)
{
try
{
var result = await _mediator.Send(new DeleteInventoryItemCommand(id), ct);
if (!result)
return NotFound(ApiResponse<bool>.Fail("Inventory item not found"));
return Ok(ApiResponse<bool>.Ok(true));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting inventory item");
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
}
}
/// <summary>
/// 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.

View File

@@ -26,10 +26,6 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="7.2.0" />
<!-- EN: API Versioning / VI: API Versioning -->
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<!-- EN: Health checks / VI: Health checks -->
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />

View File

@@ -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<Program>();
// 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();

View File

@@ -92,4 +92,10 @@ public interface IInventoryRepository : IRepository<InventoryItem>
/// VI: Thêm inventory item bất đồng bộ.
/// </summary>
Task<InventoryItem> AddAsync(InventoryItem item, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Delete an inventory item.
/// VI: Xóa một inventory item.
/// </summary>
void Delete(InventoryItem item);
}

View File

@@ -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
/// </summary>
public Guid ShopId => _shopId;
/// <summary>
/// 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).
/// </summary>
public string? Name => _name;
/// <summary>
/// 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).
/// </summary>
public ItemType ItemType => _itemType;
/// <summary>
/// EN: Item type ID for EF Core mapping.
/// VI: Item type ID cho EF Core mapping.
/// </summary>
public int ItemTypeId { get; private set; }
/// <summary>
/// EN: Unit of measure (e.g., "g", "ml", "pcs", "kg", "L").
/// VI: Đơn vị tính (ví dụ: "g", "ml", "cái", "kg", "L").
/// </summary>
public string Unit => _unit;
/// <summary>
/// EN: Cost per unit (purchase price).
/// VI: Giá mỗi đơn vị (giá mua).
/// </summary>
public decimal CostPerUnit => _costPerUnit;
/// <summary>
/// EN: Supplier name reference.
/// VI: Tên nhà cung cấp.
/// </summary>
public string? SupplierName => _supplierName;
/// <summary>
/// EN: Expiry date for perishable items.
/// VI: Ngày hết hạn cho hàng dễ hỏng.
/// </summary>
public DateTime? ExpiryDate => _expiryDate;
/// <summary>
/// EN: Total quantity in stock.
/// VI: Tổng số lượng trong kho.
@@ -86,8 +134,8 @@ public class InventoryItem : Entity, IAggregateRoot
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// EN: Stock in operation.
/// VI: Thao tác nhập kho.
/// </summary>
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));
}
/// <summary>
/// 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).
/// </summary>
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));
}
}

View File

@@ -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;
/// <summary>
@@ -54,6 +56,18 @@ public class InventoryTransaction : Entity
/// </summary>
public string? Notes => _notes;
/// <summary>
/// EN: URL to uploaded invoice image (for stock-in).
/// VI: URL ảnh hóa đơn đã tải lên (cho nhập kho).
/// </summary>
public string? InvoiceImageUrl => _invoiceImageUrl;
/// <summary>
/// EN: Cost per unit at time of stock-in.
/// VI: Giá mỗi đơn vị tại thời điểm nhập kho.
/// </summary>
public decimal? UnitCost => _unitCost;
/// <summary>
/// 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;
}
}

View File

@@ -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;
/// <summary>
/// EN: Item type enumeration - classifies inventory items.
/// VI: Enumeration loại item - phân loại inventory items.
/// </summary>
public class ItemType : Enumeration
{
/// <summary>
/// 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).
/// </summary>
public static readonly ItemType RawMaterial = new(1, nameof(RawMaterial));
/// <summary>
/// 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).
/// </summary>
public static readonly ItemType FinishedGood = new(2, nameof(FinishedGood));
/// <summary>
/// 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).
/// </summary>
public static readonly ItemType Consumable = new(3, nameof(Consumable));
public ItemType(int id, string name) : base(id, name)
{
}
}

View File

@@ -41,6 +41,12 @@ public class TransactionType : Enumeration
/// </summary>
public static readonly TransactionType Release = new(5, nameof(Release));
/// <summary>
/// EN: Wastage/shrinkage - damaged, expired, spilled items.
/// VI: Hao hụt - hàng hư hỏng, hết hạn, đổ tràn.
/// </summary>
public static readonly TransactionType Wastage = new(6, nameof(Wastage));
public TransactionType(int id, string name) : base(id, name)
{
}

View File

@@ -18,12 +18,26 @@ public class InventoryItemEntityTypeConfiguration : IEntityTypeConfiguration<Inv
builder.Property(i => i.ProductId).HasField("_productId").HasColumnName("product_id").IsRequired();
builder.Property(i => i.ShopId).HasField("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<string?>("_name").HasColumnName("name").HasMaxLength(200);
builder.Property(i => i.ItemTypeId).HasColumnName("item_type_id").IsRequired().HasDefaultValue(2);
builder.Property<string>("_unit").HasColumnName("unit").HasMaxLength(20).IsRequired().HasDefaultValue("pcs");
builder.Property<decimal>("_costPerUnit").HasColumnName("cost_per_unit").HasPrecision(18, 4).HasDefaultValue(0m);
builder.Property<string?>("_supplierName").HasColumnName("supplier_name").HasMaxLength(200);
builder.Property<DateTime?>("_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<Inv
txn.Property<int>("_quantity").HasColumnName("quantity").IsRequired();
txn.Property<Guid?>("_referenceId").HasColumnName("reference_id");
txn.Property<string?>("_notes").HasColumnName("notes").HasMaxLength(500);
txn.Property<string?>("_invoiceImageUrl").HasColumnName("invoice_image_url").HasMaxLength(1000);
txn.Property<decimal?>("_unitCost").HasColumnName("unit_cost").HasPrecision(18, 4);
txn.Property<DateTime>("_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);
});

View File

@@ -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<ItemType>
{
public void Configure(EntityTypeBuilder<ItemType> 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
);
}
}

View File

@@ -22,7 +22,8 @@ public class TransactionTypeEntityTypeConfiguration : IEntityTypeConfiguration<T
TransactionType.Out,
TransactionType.Adjustment,
TransactionType.Reserve,
TransactionType.Release
TransactionType.Release,
TransactionType.Wastage
);
}
}

View File

@@ -44,10 +44,12 @@ public class InventoryContext : DbContext, IUnitOfWork
{
modelBuilder.ApplyConfiguration(new InventoryItemEntityTypeConfiguration());
// EN: Ignore TransactionType so EF Core does NOT auto-discover TypeId as a FK.
// TransactionType is a DDD Enumeration resolved in-memory.
// VI: Ignore TransactionType để EF Core KHÔNG tự phát hiện TypeId là FK.
// EN: Ignore Enumeration types so EF Core does NOT auto-discover TypeId as a FK.
// TransactionType and ItemType are DDD Enumerations resolved in-memory.
// VI: Ignore các Enumeration type để EF Core KHÔNG tự phát hiện TypeId là FK.
// TransactionType và ItemType là DDD Enumerations xử lý trong bộ nhớ.
modelBuilder.Ignore<TransactionType>();
modelBuilder.Ignore<ItemType>();
}
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)

View File

@@ -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);
}
}

View File

@@ -28,7 +28,8 @@ public record OrderItemRequest(
string ProductName,
string ProductType,
int Quantity,
decimal UnitPrice
decimal UnitPrice,
bool TrackInventory = true
);
/// <summary>

View File

@@ -48,7 +48,8 @@ public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Cre
itemRequest.ProductName,
itemRequest.ProductType,
itemRequest.Quantity,
itemRequest.UnitPrice);
itemRequest.UnitPrice,
trackInventory: itemRequest.TrackInventory);
order.AddItem(orderItem);
}

View File

@@ -14,15 +14,18 @@ namespace OrderService.API.Application.Strategies;
public class FnbStrategy : ILineItemStrategy
{
private readonly FnbEngineClient _fnbClient;
private readonly InventoryServiceClient _inventoryClient;
private readonly ILogger<FnbStrategy> _logger;
public string SupportedType => "PreparedFood";
public FnbStrategy(
FnbEngineClient fnbClient,
InventoryServiceClient inventoryClient,
ILogger<FnbStrategy> 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);
}
}
}

View File

@@ -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.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/admin/orders")]
[Route("api/v1/admin/orders")]
public class AdminOrdersController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -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.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/orders")]
[Route("api/v1/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -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.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/reports")]
[Route("api/v1/reports")]
public class ReportsController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -65,6 +65,33 @@ public class FnbEngineClient
return null;
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<RecipeWithIngredientsResult?> 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<ApiResponseWrapper<RecipeWithIngredientsResult>>(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<RecipeIngredientResult> Ingredients);
public record RecipeIngredientResult(
Guid Id, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit,
Guid? InventoryItemId, decimal QuantityPerServing);
public record ApiResponseWrapper<T>(bool Success, T? Data, string? Error);

View File

@@ -86,6 +86,36 @@ public class InventoryServiceClient
return false;
}
}
/// <summary>
/// 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).
/// </summary>
public async Task<bool> 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);

View File

@@ -25,10 +25,6 @@
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<!-- EN: API Versioning / VI: API Versioning -->
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<!-- EN: Health checks / VI: Health checks -->
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />

View File

@@ -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<Program>();
// 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();

View File

@@ -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
/// <summary>
@@ -62,6 +63,12 @@ public class OrderItem : Entity
/// </summary>
public string Status => _status;
/// <summary>
/// EN: Whether this item should auto-deduct inventory when ordered.
/// VI: Có tự động trừ kho khi đặt hàng hay không.
/// </summary>
public bool TrackInventory => _trackInventory;
/// <summary>
/// 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;
}
/// <summary>

View File

@@ -110,6 +110,11 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
.HasMaxLength(50)
.IsRequired();
orderItems.Property<bool>("_trackInventory")
.HasColumnName("track_inventory")
.HasDefaultValue(true)
.IsRequired();
orderItems.Property<string?>("_metadata")
.HasColumnName("metadata")
.HasColumnType("jsonb");
@@ -121,6 +126,7 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
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);
});