feat: enhance inventory management with new item types, stocktake, wastage, and recipe-based deductions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ public class TransactionTypeEntityTypeConfiguration : IEntityTypeConfiguration<T
|
||||
TransactionType.Out,
|
||||
TransactionType.Adjustment,
|
||||
TransactionType.Reserve,
|
||||
TransactionType.Release
|
||||
TransactionType.Release,
|
||||
TransactionType.Wastage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ public record OrderItemRequest(
|
||||
string ProductName,
|
||||
string ProductType,
|
||||
int Quantity,
|
||||
decimal UnitPrice
|
||||
decimal UnitPrice,
|
||||
bool TrackInventory = true
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user