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