feat: implement recipe management, inventory operations, voucher integration, and order discounts

This commit is contained in:
Ho Ngoc Hai
2026-03-04 20:05:38 +07:00
parent 65f3da53ae
commit 051261accd
40 changed files with 1166 additions and 68 deletions

View File

@@ -192,7 +192,7 @@
case "products":
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@(_products.Count) sản phẩm</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingProductId = null; _newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductType = "PreparedFood"; _formMessage = null; _showProductForm = !_showProductForm; })">
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingProductId = null; _newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductType = "PreparedFood"; _newProductCategoryId = ""; _formMessage = null; _showProductForm = !_showProductForm; })">
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>
Thêm sản phẩm
</button>
@@ -213,6 +213,15 @@
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mô tả</label><input type="text" @bind="_newProductDesc" class="admin-input" placeholder="Mô tả ngắn" 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);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Danh mục</label>
<select @bind="_newProductCategoryId" 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);">
<option value="">-- Không phân loại --</option>
@foreach (var c in _categories)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="admin-btn-primary" @onclick="@(_editingProductId.HasValue ? SaveProduct : AddProduct)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingProductId.HasValue ? "Cập nhật" : "Lưu")</button>
@@ -313,35 +322,238 @@
// ═══ INVENTORY ═══
case "inventory":
@if (!_inventory.Any())
@if (!_inventory.Any() && _invSubTab == "levels")
{
@RenderEmpty("warehouse", "#3B82F6", "Chưa có tồn kho", "Tồn kho sẽ hiển thị khi có sản phẩm", "package", "Thêm sản phẩm trước", $"/admin/shop/{ShopId}/menu")
}
else
{
<!-- Stats cards -->
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="check-circle" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity > 10)</span><span class="admin-stat-card__label">Còn hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);"><i data-lucide="alert-triangle" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity > 0 && i.Quantity <= 10)</span><span class="admin-stat-card__label">Sắp hết</span></div></div>
<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>
<div class="admin-panel">
<div class="admin-panel__body" style="padding:0;">
<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: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);">Mức nhập lại</th>
</tr></thead><tbody>
@foreach (var item in _inventory)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<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:600;color:@(item.Quantity <= 0 ? "#EF4444" : item.Quantity <= 10 ? "#F59E0B" : "#22C55E");">@item.Quantity</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.ReorderLevel</td>
</tr>
}
</tbody></table>
</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") })
{
<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;
@(_invSubTab == val ? "background:white;color:var(--admin-text-primary);box-shadow:0 1px 3px rgba(0,0,0,0.1);" : "background:transparent;color:var(--admin-text-tertiary);")">
<i data-lucide="@icon" style="width:14px;height:14px;"></i> @label
</button>
}
</div>
@if (_invFormMessage != null)
{
<div style="margin-top:12px;padding:12px 16px;border-radius:8px;font-size:13px;font-weight:500;background:@(_invFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_invFormSuccess ? "#16A34A" : "#DC2626");">
@_invFormMessage
</div>
}
@switch (_invSubTab)
{
case "levels":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__body" style="padding:0;">
<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: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);">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>
@foreach (var item in _inventory)
{
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";
<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: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;">
<button @onclick="@(() => { _invSubTab = "stock-in"; _invSelectedProductId = item.ProductId; _invAmount = 0; _invNotes = ""; StateHasChanged(); })"
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>
</div>
</td>
</tr>
}
</tbody></table>
</div>
</div>
break;
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>
<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);">
<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>
}
</select>
</div>
<div>
<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);" 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);">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);" 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>
</div>
</div>
break;
case "stock-out":
<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-up-from-line" style="width:18px;height:18px;margin-right:8px;color:#EF4444;"></i>Xuất kho</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);">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);">
<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>
}
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng xuất *</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);" 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);">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);" placeholder="Lý do xuất kho..." />
</div>
<button @onclick="DoStockOut" style="padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="arrow-up-from-line" style="width:16px;height:16px;margin-right:6px;"></i>Xuất kho
</button>
</div>
</div>
break;
case "adjust":
<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="settings-2" style="width:18px;height:18px;margin-right:8px;color:#3B82F6;"></i>Điều chỉnh tồn kho</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);">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);">
<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>
}
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng mới *</label>
<input type="number" @bind="_invNewQty" 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);" placeholder="Đặt số lượng chính xác..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Lý do *</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);" placeholder="Lý do điều chỉnh..." />
</div>
<button @onclick="DoAdjustStock" style="padding:10px 20px;background:#3B82F6;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="settings-2" style="width:16px;height:16px;margin-right:6px;"></i>Điều chỉnh
</button>
</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>
<div class="admin-panel__body" style="padding:0;">
@if (_invTxns.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);">Thời gian</th>
<th style="padding:12px 16px;text-align:left;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:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Lý do</th>
</tr></thead><tbody>
@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" };
<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;">
<span style="padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:@(txColor)1a;color:@txColor;">@txLabel</span>
</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@txColor;">@(tx.QuantityChange > 0 ? "+" : "")@tx.QuantityChange</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(tx.Reason ?? "—")</td>
</tr>
}
</tbody></table>
}
else
{
<div style="text-align:center;padding:32px;color:var(--admin-text-tertiary);font-size:14px;">Chưa có giao dịch kho nào.</div>
}
</div>
</div>
break;
case "low-stock":
<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="alert-triangle" style="width:18px;height:18px;margin-right:8px;color:#F59E0B;"></i>Cảnh báo tồn kho thấp</h3>
<button @onclick="LoadLowStock" style="padding:6px 14px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:var(--admin-text-secondary);">
<i data-lucide="refresh-cw" style="width:12px;height:12px;margin-right:4px;"></i>Làm mới
</button>
</div>
<div class="admin-panel__body" style="padding:0;">
@if (_lowStockItems.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);">Sản phẩm</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tồn kho</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngưỡng</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Hành động</th>
</tr></thead><tbody>
@foreach (var item in _lowStockItems)
{
<tr style="border-top:1px solid var(--admin-border-subtle);background:rgba(245,158,11,0.03);">
<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:@(item.Quantity <= 0 ? "#EF4444" : "#F59E0B");">@item.Quantity</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.LowStockThreshold</td>
<td style="padding:12px 16px;text-align:center;">
<button @onclick="@(() => { _invSubTab = "stock-in"; _invSelectedProductId = item.ProductId; _invAmount = item.LowStockThreshold * 2; _invNotes = "Bổ sung hàng tồn kho thấp"; StateHasChanged(); })"
style="background:rgba(34,197,94,0.1);border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:600;color:#16A34A;cursor:pointer;">
<i data-lucide="arrow-down-to-line" style="width:12px;height:12px;margin-right:4px;"></i>Nhập kho nhanh
</button>
</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/>
Tất cả sản phẩm đều đủ hàng!
</div>
}
</div>
</div>
break;
}
}
break;
@@ -463,7 +675,7 @@
case "staff":
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@(_staff.Count) nhân viên</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _showStaffForm = !_showStaffForm; })">
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _createStaffAccount = false; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _showStaffForm = !_showStaffForm; })">
<i data-lucide="user-plus" style="width:16px;height:16px;"></i>
Thêm nhân viên
</button>
@@ -485,8 +697,24 @@
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">SĐT</label><input type="text" @bind="_newStaffPhone" placeholder="0912345678" 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);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Email</label><input type="text" @bind="_newStaffEmail" placeholder="nv@goodgo.vn" 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);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Email *</label><input type="email" @bind="_newStaffEmail" placeholder="nv@goodgo.vn" 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);" /></div>
</div>
@if (!_editingStaffId.HasValue)
{
<div style="margin-top:12px;padding:12px;border-radius:8px;background:rgba(139,92,246,0.05);border:1px solid rgba(139,92,246,0.2);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;">
<input type="checkbox" @bind="_createStaffAccount" /> Tạo tài khoản đăng nhập (IAM)
</label>
@if (_createStaffAccount)
{
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:10px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Họ *</label><input type="text" @bind="_newStaffLastName" placeholder="Nguyễn" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:white;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên *</label><input type="text" @bind="_newStaffFirstName" placeholder="Văn A" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:white;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mật khẩu *</label><input type="password" @bind="_newStaffPassword" placeholder="Min 8 ký tự" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:white;color:var(--admin-text-primary);" /></div>
</div>
}
</div>
}
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="admin-btn-primary" @onclick="@(_editingStaffId.HasValue ? SaveStaffEdit : AddStaff)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingStaffId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick="@(() => _showStaffForm = false)" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
@@ -548,7 +776,7 @@
<input type="text" placeholder="Tìm theo ID..." @bind="_customerSearch" @bind:event="oninput"
style="padding:8px 12px 8px 36px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);width:200px;" />
</div>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showMemberForm = !_showMemberForm; _editingMemberId = null; _newMemberGender = ""; _newMemberCountry = "VN"; }'>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showMemberForm = !_showMemberForm; _editingMemberId = null; _newMemberGender = ""; _newMemberCountry = "VN"; _newMemberName = ""; _newMemberPhone = ""; }'>
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm khách hàng
</button>
</div>
@@ -559,6 +787,8 @@
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingMemberId.HasValue ? "Sửa khách hàng" : "Thêm khách hàng")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên khách hàng *</label><input type="text" @bind="_newMemberName" placeholder="Nguyễn Văn A" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số điện thoại</label><input type="tel" @bind="_newMemberPhone" placeholder="0901234567" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giới tính</label>
<select @bind="_newMemberGender" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
<option value="">-- Chọn --</option>
@@ -579,6 +809,8 @@
var filteredMembers = string.IsNullOrWhiteSpace(_customerSearch)
? _members
: _members.Where(m => m.Id.ToString().Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|| (m.DisplayName ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|| (m.Phone ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|| (m.LevelName ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)).ToList();
@if (!filteredMembers.Any())
{
@@ -619,7 +851,8 @@
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách khách hàng</h3></div>
<div class="admin-panel__body" style="padding:0;">
<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);">ID</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">SĐT</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Cấp bậc</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">EXP</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày tham gia</th>
@@ -629,7 +862,8 @@
{
var isExpanded = _selectedCustomerId == m.Id;
<tr style="border-top:1px solid var(--admin-border-subtle);cursor:pointer;transition:background 0.15s;@(isExpanded ? "background:rgba(255,92,0,0.05);" : "")" @onclick='() => { _selectedCustomerId = isExpanded ? null : m.Id; StateHasChanged(); }'>
<td style="padding:12px 16px;font-weight:600;font-family:monospace;font-size:12px;">@m.Id.ToString()[..8]</td>
<td style="padding:12px 16px;font-weight:600;">@(m.DisplayName ?? m.Id.ToString()[..8])</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@(m.Phone ?? "—")</td>
<td style="padding:12px 16px;"><span class="admin-status-badge admin-status-badge--online" style="font-size:11px;padding:2px 10px;">@(m.LevelName ?? "—")</span></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@m.TotalExpEarned.ToString("N0")</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@m.CreatedAt.ToString("dd/MM/yyyy")</td>
@@ -1926,6 +2160,7 @@
private decimal _newProductPrice;
private string _newProductType = "PreparedFood";
private string _newProductDesc = "";
private string _newProductCategoryId = "";
private string? _formMessage;
private bool _formSuccess;
// Staff form state
@@ -1937,6 +2172,10 @@
private string _newStaffEmail = "";
private string? _staffFormMessage;
private bool _staffFormSuccess;
private bool _createStaffAccount;
private string _newStaffFirstName = "";
private string _newStaffLastName = "";
private string _newStaffPassword = "";
private Guid? _merchantId;
// New data: wallets, promotions, campaigns, member levels, schedules, inv txns
private List<PosDataService.WalletInfo> _wallets = new();
@@ -1960,6 +2199,8 @@
private Guid? _editingMemberId;
private string _newMemberGender = "";
private string _newMemberCountry = "VN";
private string _newMemberName = "";
private string _newMemberPhone = "";
private List<PosDataService.ScheduleInfo> _staffSchedules = new();
private List<PosDataService.InventoryTxnInfo> _invTxns = new();
// P2 state: calendar, KDS, treatments
@@ -1972,6 +2213,15 @@
private List<PosDataService.ResourceInfo> _resources = new();
// Customer filter state
private string _customerSearch = "";
// Inventory sub-tab and form state
private string _invSubTab = "levels"; // levels, stock-in, stock-out, adjust, transactions, low-stock
private Guid _invSelectedProductId;
private int _invAmount;
private int _invNewQty;
private string _invNotes = "";
private string? _invFormMessage;
private bool _invFormSuccess;
private List<PosDataService.LowStockItemInfo> _lowStockItems = new();
// Finance date range filter state
private string _financePeriod = "all"; // 7d, 30d, all
// Category form state
@@ -2305,10 +2555,11 @@
}
try
{
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid) ? cid : null;
await DataService.CreateProductAsync(new PosDataService.CreateProductRequest(
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null));
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null, catId));
_formMessage = $"Đã thêm '{_newProductName}' thành công!"; _formSuccess = true;
_newProductName = ""; _newProductPrice = 0; _newProductDesc = "";
_newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductCategoryId = "";
_products = await DataService.GetAllProductsAsync(_shopGuid);
}
catch (Exception ex) { _formMessage = $"Lỗi: {ex.Message}"; _formSuccess = false; }
@@ -2331,6 +2582,7 @@
_newProductPrice = p.Price;
_newProductType = p.Type ?? "PreparedFood";
_newProductDesc = p.Description ?? "";
_newProductCategoryId = p.CategoryId?.ToString() ?? "";
_formMessage = null;
_showProductForm = true;
}
@@ -2344,8 +2596,9 @@
}
try
{
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid2) ? cid2 : null;
await DataService.UpdateProductAsync(_editingProductId.Value, new PosDataService.CreateProductRequest(
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null));
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null, catId));
_formMessage = $"Đã cập nhật '{_newProductName}' thành công!"; _formSuccess = true;
_editingProductId = null;
_products = await DataService.GetAllProductsAsync(_shopGuid);
@@ -2362,10 +2615,25 @@
}
try
{
await DataService.CreateStaffAsync(new PosDataService.CreateStaffRequest(
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole));
_staffFormMessage = $"Đã thêm NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
_newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = "";
if (_createStaffAccount)
{
if (string.IsNullOrWhiteSpace(_newStaffEmail) || string.IsNullOrWhiteSpace(_newStaffPassword)
|| string.IsNullOrWhiteSpace(_newStaffFirstName) || string.IsNullOrWhiteSpace(_newStaffLastName))
{
_staffFormMessage = "Vui lòng nhập đầy đủ email, mật khẩu, họ và tên."; _staffFormSuccess = false; return;
}
var ok = await DataService.InviteStaffWithAccountAsync(new PosDataService.InviteStaffWithAccountRequest(
_newStaffEmail, _newStaffPassword, _newStaffFirstName, _newStaffLastName, _newStaffRole, _shopGuid));
if (!ok) { _staffFormMessage = "Lỗi tạo tài khoản IAM. Kiểm tra email/mật khẩu."; _staffFormSuccess = false; return; }
_staffFormMessage = $"Đã tạo tài khoản + mời NV '{_newStaffEmail}' thành công!"; _staffFormSuccess = true;
}
else
{
await DataService.CreateStaffAsync(new PosDataService.CreateStaffRequest(
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole));
_staffFormMessage = $"Đã thêm NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
}
_newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _createStaffAccount = false;
_staff = await DataService.GetStaffAsync();
}
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
@@ -2434,6 +2702,47 @@
private void EditCategory(PosDataService.AdminCategoryInfo c) { _editingCategoryId = c.Id; _newCategoryName = c.Name ?? ""; _newCategoryDesc = c.Description ?? ""; _newCategoryOrder = c.DisplayOrder; _showCategoryForm = true; _categoryFormMessage = null; }
private async Task DeleteCategoryItem(Guid id) { await DataService.DeleteCategoryAsync(id); _categories = await DataService.GetAllCategoriesAsync(_shopGuid); }
// ═══ INVENTORY OPERATIONS ═══
private async Task SwitchInvSubTab(string tab)
{
_invSubTab = tab;
_invFormMessage = null;
if (tab == "low-stock") await LoadLowStock();
StateHasChanged();
}
private async Task LoadLowStock()
{
_lowStockItems = await DataService.GetLowStockAsync(_shopGuid);
StateHasChanged();
}
private async Task DoStockIn()
{
_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, _shopGuid!.Value, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes));
_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); }
}
private async Task DoStockOut()
{
_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.StockOutAsync(new PosDataService.StockOutRequest(_invSelectedProductId, _shopGuid!.Value, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes));
_invFormMessage = ok ? $"Đã xuất kho thành công -{_invAmount}!" : "Lỗi khi xuất kho. Kiểm tra số lượng tồn.";
_invFormSuccess = ok;
if (ok) { _invAmount = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
}
private async Task DoAdjustStock()
{
_invFormMessage = null;
if (_invSelectedProductId == Guid.Empty || string.IsNullOrWhiteSpace(_invNotes)) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập lý do điều chỉnh."; _invFormSuccess = false; return; }
var ok = await DataService.AdjustStockAsync(new PosDataService.AdjustStockRequest(_invSelectedProductId, _shopGuid!.Value, _invNewQty, _invNotes));
_invFormMessage = ok ? $"Đã điều chỉnh tồn kho = {_invNewQty}!" : "Lỗi khi điều chỉnh.";
_invFormSuccess = ok;
if (ok) { _invNewQty = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
}
// ═══ ORDER DETAIL ═══
private async Task ViewOrderDetail(Guid orderId) { if (_selectedOrderId == orderId) { _selectedOrderId = null; _orderDetail = null; return; } _selectedOrderId = orderId; _orderDetail = await DataService.GetOrderDetailAsync(orderId); }
private async Task CancelOrderItem(Guid orderId) { var ok = await DataService.CancelOrderAsync(orderId); if (ok) { _selectedOrderId = null; _orderDetail = null; await LoadData(); } }
@@ -2493,13 +2802,16 @@
if (_editingMemberId.HasValue)
ok = await DataService.UpdateMemberAsync(_editingMemberId.Value, new PosDataService.UpdateMemberRequest(_newMemberGender, null));
else
ok = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry));
if (ok) { _showMemberForm = false; _editingMemberId = null; _members = await DataService.GetMembersAsync(); }
ok = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry,
string.IsNullOrWhiteSpace(_newMemberName) ? null : _newMemberName,
string.IsNullOrWhiteSpace(_newMemberPhone) ? null : _newMemberPhone));
if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _members = await DataService.GetMembersAsync(); }
}
private void EditMember(PosDataService.MemberInfo m)
{
_editingMemberId = m.Id; _newMemberGender = m.Gender ?? ""; _newMemberCountry = m.CountryCode ?? "VN";
_newMemberName = m.DisplayName ?? ""; _newMemberPhone = m.Phone ?? "";
_showMemberForm = true;
}

View File

@@ -83,9 +83,29 @@
}
</div>
<div class="pos-cart-footer">
<!-- Voucher input -->
<div style="display:flex;gap:6px;margin-bottom:8px;">
<input type="text" @bind="_voucherCode" placeholder="Nhập mã voucher..." style="flex:1;padding:8px 10px;border:1px solid var(--pos-border);border-radius:8px;font-size:12px;background:var(--pos-bg-elevated);color:var(--pos-text-primary);" />
<button @onclick="ValidateVoucher" style="padding:6px 12px;border-radius:8px;border:none;background:rgba(139,92,246,0.1);color:#8B5CF6;font-size:11px;font-weight:600;cursor:pointer;">Áp dụng</button>
@if (_appliedVoucher != null)
{
<button @onclick="ClearVoucher" style="padding:6px 8px;border-radius:8px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;font-size:11px;cursor:pointer;" title="Xóa voucher"><i data-lucide="x" style="width:12px;height:12px;"></i></button>
}
</div>
@if (_voucherMessage != null)
{
<div style="font-size:11px;margin-bottom:6px;color:@(_appliedVoucher != null ? "#16A34A" : "#EF4444");">@_voucherMessage</div>
}
@if (_appliedVoucher != null)
{
<div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:12px;">
<span style="color:var(--pos-text-tertiary);">Giảm giá (@_appliedVoucher.CampaignName)</span>
<span style="color:#16A34A;font-weight:600;">-@FormatPrice(_discountAmount)</span>
</div>
}
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(CartTotal)</span>
<span class="pos-cart-total__value">@FormatPrice(FinalTotal)</span>
</div>
<button class="pos-btn-checkout" @onclick="StartPayment" disabled="@(!_cartItems.Any())">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
@@ -102,7 +122,7 @@
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;">Thanh toán</span>
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</span>
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
</div>
<div class="pos-payment-methods">
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("cash")'>
@@ -133,7 +153,7 @@
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;">💵 Tiền mặt</span>
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</span>
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
</div>
<div class="pos-payment-amount-section">
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:10px;">Số tiền nhanh</div>
@@ -168,7 +188,7 @@
</div>
</div>
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < CartTotal)">
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < FinalTotal)">
Xác nhận thanh toán
</button>
</div>
@@ -185,7 +205,7 @@
<span style="font-weight:600;">@GetMethodLabel()</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;">
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</div>
@if (_selectedMethod == "qr")
{
<div style="width:180px;height:180px;background:white;border-radius:12px;display:flex;align-items:center;justify-content:center;">
@@ -464,6 +484,13 @@
private IEnumerable<Product> FilteredProducts =>
_selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory);
private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty);
private decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount);
// Voucher state
private string _voucherCode = "";
private string? _voucherMessage;
private PosDataService.VoucherValidationInfo? _appliedVoucher;
private decimal _discountAmount;
protected override async Task OnInitializedAsync()
{
@@ -478,7 +505,7 @@
var apiProducts = await productsTask;
var apiCategories = await categoriesTask;
_products = apiProducts.Select(p => new Product(p.Id, p.Name, p.Price, p.Category ?? "Khác")).ToList();
_products = apiProducts.Select(p => new Product(p.Id, p.Name, p.Price, p.CategoryName ?? "Khác")).ToList();
var catNames = apiCategories.Select(c => c.Name).ToList();
if (catNames.Count > 0)
@@ -507,6 +534,20 @@
if (item.Qty <= 0) _cartItems.Remove(item);
}
// ═══════════════ VOUCHER ═══════════════
private async Task ValidateVoucher()
{
_voucherMessage = null; _appliedVoucher = null; _discountAmount = 0;
if (string.IsNullOrWhiteSpace(_voucherCode)) { _voucherMessage = "Vui lòng nhập mã voucher."; return; }
var info = await DataService.ValidateVoucherAsync(_voucherCode.Trim());
if (info == null) { _voucherMessage = "Không thể kiểm tra voucher."; return; }
if (!info.IsValid) { _voucherMessage = info.ErrorMessage ?? "Mã voucher không hợp lệ."; return; }
_appliedVoucher = info;
_discountAmount = Math.Min(info.RemainingValue ?? 0, CartTotal);
_voucherMessage = $"Voucher {info.CampaignName}: giảm {FormatPrice(_discountAmount)}";
}
private void ClearVoucher() { _appliedVoucher = null; _discountAmount = 0; _voucherCode = ""; _voucherMessage = null; }
// ═══════════════ INLINE PAYMENT ═══════════════
private enum PayStep { None, MethodSelect, AmountInput, Processing, Success }
private PayStep _paymentStep = PayStep.None;
@@ -517,7 +558,7 @@
private string _lastTransactionId = "";
private string _lastPaymentMethod = "";
private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new();
private decimal ChangeAmount => _receivedAmount - CartTotal;
private decimal ChangeAmount => _receivedAmount - FinalTotal;
private void StartPayment()
{
@@ -556,7 +597,7 @@
private List<(string Label, decimal Value)> GetQuickAmounts()
{
var total = CartTotal;
var total = FinalTotal;
var amounts = new List<(string, decimal)>();
var roundUp = Math.Ceiling(total / 50_000) * 50_000;
if (roundUp == total) roundUp += 50_000;
@@ -583,7 +624,7 @@
_paymentProcessing = true;
StateHasChanged();
_lastOrderTotal = CartTotal;
_lastOrderTotal = FinalTotal;
_lastPaymentMethod = _selectedMethod;
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" };
@@ -596,7 +637,10 @@
ShopId,
_selectedMethod,
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, i.Qty, i.Price)).ToList());
i.ProductId, i.Name, i.Qty, i.Price)).ToList(),
_discountAmount > 0 ? _discountAmount : null,
_appliedVoucher != null ? "voucher" : null,
_appliedVoucher?.VoucherCode);
var result = await DataService.CreatePosOrderAsync(orderReq);
_lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
@@ -636,6 +680,7 @@
_selectedMethod = "";
_receivedAmount = 0;
_customAmountInput = "";
ClearVoucher();
}
/// <summary>

View File

@@ -109,7 +109,7 @@ public class PosDataService
}
public record ShopInfo(Guid Id, string Name, string Slug, string? Description, string? Phone, string? Email, string? Category, string? Status, Guid? MerchantId = null);
public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? Category, int? DurationMinutes);
public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? CategoryName, int? DurationMinutes, Guid? CategoryId = null);
public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder);
public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt);
public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName);
@@ -141,11 +141,11 @@ public class PosDataService
// EN: Admin-level records with shop_id and category info
// VI: Record cấp admin với shop_id và thông tin danh mục
public record AdminProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description,
string? ImageUrl, bool IsActive, string? Type, Guid ShopId, DateTime CreatedAt, string? CategoryName);
string? ImageUrl, bool IsActive, string? Type, Guid ShopId, DateTime CreatedAt, string? CategoryName, Guid? CategoryId = null);
public record AdminCategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder,
Guid ShopId, Guid? ParentId, bool IsActive);
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price,
string? Type, string? Sku, string? ImageUrl);
string? Type, string? Sku, string? ImageUrl, Guid? CategoryId = null);
public async Task<List<AdminProductInfo>> GetAllProductsAsync(Guid? shopId = null)
{
@@ -194,10 +194,14 @@ public class PosDataService
// ═══ MEMBERSHIP/CUSTOMER METHODS ═══
public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp,
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName);
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName,
string? DisplayName = null, string? Phone = null);
public async Task<List<MemberInfo>> GetMembersAsync()
=> await GetListFromApiAsync<MemberInfo>("api/bff/members");
public async Task<List<MemberInfo>> GetMembersAsync(string? search = null)
{
var url = string.IsNullOrWhiteSpace(search) ? "api/bff/members" : $"api/bff/members?search={Uri.EscapeDataString(search)}";
return await GetListFromApiAsync<MemberInfo>(url);
}
// ═══ STAFF CREATE ═══
@@ -323,7 +327,7 @@ public class PosDataService
// EN: Member create/update request DTOs
// VI: DTO tạo/cập nhật thành viên
public record CreateMemberRequest(string? Gender, string? CountryCode);
public record CreateMemberRequest(string? Gender, string? CountryCode, string? Name = null, string? Phone = null);
public record UpdateMemberRequest(string? Gender, string? Preferences);
public async Task<bool> CreateMemberAsync(CreateMemberRequest req)
@@ -357,6 +361,43 @@ public class PosDataService
return await GetListFromApiAsync<InventoryTxnInfo>(url);
}
// ═══ INVENTORY OPERATIONS ═══
public record StockInRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes);
public async Task<bool> StockInAsync(StockInRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-in", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public record StockOutRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes);
public async Task<bool> StockOutAsync(StockOutRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-out", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public record AdjustStockRequest(Guid ProductId, Guid ShopId, int NewQuantity, string Notes);
public async Task<bool> AdjustStockAsync(AdjustStockRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/adjust", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public record LowStockItemInfo(Guid Id, Guid ProductId, string? ProductName, int Quantity, int LowStockThreshold, Guid ShopId);
public async Task<List<LowStockItemInfo>> GetLowStockAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/inventory/low-stock?shopId={shopId}" : "api/bff/inventory/low-stock";
return await GetListFromApiAsync<LowStockItemInfo>(url);
}
// ═══ MEMBERSHIP LEVELS ═══
public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount);
@@ -417,7 +458,8 @@ public class PosDataService
// EN: POS order creation DTOs
// VI: DTOs cho tạo đơn POS
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items);
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items,
decimal? DiscountAmount = null, string? DiscountType = null, string? DiscountReference = null);
public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice, string? ProductType = "Physical");
public record CreatePosOrderResponse(Guid OrderId, string TransactionId, decimal TotalAmount, string Status);
@@ -623,4 +665,38 @@ public class PosDataService
public async Task<bool> DeleteRecipeAsync(Guid recipeId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/recipes/{recipeId}"); return r.IsSuccessStatusCode; }
// ═══ VOUCHER VALIDATION ═══
public record VoucherValidationInfo(bool IsValid, string? ErrorMessage, Guid? VoucherId,
string? VoucherCode, decimal? RemainingValue, DateTime? ExpiresAt, string? CampaignName);
public async Task<VoucherValidationInfo?> ValidateVoucherAsync(string code)
=> await GetObjectFromApiAsync<VoucherValidationInfo>($"api/bff/vouchers/validate/{Uri.EscapeDataString(code)}");
public async Task<bool> RedeemVoucherAsync(Guid voucherId, decimal amount)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/vouchers/redeem", new { voucherId, amount }, _writeOptions);
return resp.IsSuccessStatusCode;
}
// ═══ CAMPAIGN ACTIONS ═══
public async Task<bool> ActivateCampaignAsync(Guid campaignId)
{ AttachToken(); var r = await _http.PostAsync($"api/bff/campaigns/{campaignId}/activate", null); return r.IsSuccessStatusCode; }
public async Task<bool> PauseCampaignAsync(Guid campaignId)
{ AttachToken(); var r = await _http.PostAsync($"api/bff/campaigns/{campaignId}/pause", null); return r.IsSuccessStatusCode; }
// ═══ STAFF IAM ═══
public record InviteStaffWithAccountRequest(string Email, string Password, string FirstName, string LastName, string Role, Guid? ShopId);
public async Task<bool> InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff/invite-with-account", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
}

View File

@@ -160,4 +160,42 @@ public class FinancialController : ControllerBase
[HttpDelete("campaigns/{campaignId:guid}")]
public Task<IActionResult> DeleteCampaign(Guid campaignId) =>
_promotion.DeleteAsync($"/api/v1/campaigns/{campaignId}").ProxyAsync();
// ═══ VOUCHER ENDPOINTS ═══
/// <summary>
/// EN: Validate a voucher code.
/// VI: Kiểm tra mã voucher.
/// </summary>
[HttpGet("vouchers/validate/{code}")]
public async Task<IActionResult> ValidateVoucher(string code)
{
var userId = GetUserIdFromToken();
var qs = userId.HasValue ? $"?userId={userId}" : "";
return await _promotion.GetAsync($"/api/v1/vouchers/validate/{code}{qs}").ProxyAsync();
}
/// <summary>
/// EN: Redeem a voucher.
/// VI: Sử dụng voucher.
/// </summary>
[HttpPost("vouchers/redeem")]
public Task<IActionResult> RedeemVoucher([FromBody] JsonElement body) =>
_promotion.PostAsJsonAsync("/api/v1/vouchers/redeem", body).ProxyAsync();
/// <summary>
/// EN: Activate a campaign.
/// VI: Kích hoạt chiến dịch.
/// </summary>
[HttpPost("campaigns/{campaignId:guid}/activate")]
public Task<IActionResult> ActivateCampaign(Guid campaignId) =>
_promotion.PostAsync($"/api/v1/campaigns/{campaignId}/activate", null).ProxyAsync();
/// <summary>
/// EN: Pause a campaign.
/// VI: Tạm dừng chiến dịch.
/// </summary>
[HttpPost("campaigns/{campaignId:guid}/pause")]
public Task<IActionResult> PauseCampaign(Guid campaignId) =>
_promotion.PostAsync($"/api/v1/campaigns/{campaignId}/pause", null).ProxyAsync();
}

View File

@@ -48,4 +48,39 @@ public class InventoryController : ControllerBase
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
return _inventory.GetAsync($"/api/v1/inventory/transactions{qs}").ProxyAsync();
}
/// <summary>
/// EN: Stock in — add quantity to inventory.
/// VI: Nhập kho — thêm số lượng vào tồn kho.
/// </summary>
[HttpPost("inventory/stock-in")]
public Task<IActionResult> StockIn([FromBody] JsonElement body) =>
_inventory.PostAsJsonAsync("/api/v1/inventory/stock-in", body).ProxyAsync();
/// <summary>
/// EN: Stock out — remove quantity from inventory.
/// VI: Xuất kho — trừ số lượng khỏi tồn kho.
/// </summary>
[HttpPost("inventory/stock-out")]
public Task<IActionResult> StockOut([FromBody] JsonElement body) =>
_inventory.PostAsJsonAsync("/api/v1/inventory/stock-out", body).ProxyAsync();
/// <summary>
/// EN: Adjust stock — set exact quantity.
/// VI: Điều chỉnh kho — đặt số lượng chính xác.
/// </summary>
[HttpPost("inventory/adjust")]
public Task<IActionResult> AdjustStock([FromBody] JsonElement body) =>
_inventory.PostAsJsonAsync("/api/v1/inventory/adjust", body).ProxyAsync();
/// <summary>
/// EN: Get low stock alerts.
/// VI: Lấy cảnh báo hàng tồn kho thấp.
/// </summary>
[HttpGet("inventory/low-stock")]
public Task<IActionResult> GetLowStock([FromQuery] Guid? shopId = null)
{
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
return _inventory.GetAsync($"/api/v1/inventory/low-stock{qs}").ProxyAsync();
}
}

View File

@@ -24,8 +24,20 @@ public class MembershipController : ControllerBase
/// VI: Lấy danh sách thành viên (khách hàng).
/// </summary>
[HttpGet("members")]
public Task<IActionResult> GetMembers() =>
_membership.GetAsync("/api/v1/members").ProxyAsync();
public Task<IActionResult> GetMembers([FromQuery] string? search = null, [FromQuery] int pageSize = 100)
{
var qs = $"?pageSize={pageSize}";
if (!string.IsNullOrWhiteSpace(search)) qs += $"&search={Uri.EscapeDataString(search)}";
return _membership.GetAsync($"/api/v1/members{qs}").ProxyAsync();
}
/// <summary>
/// EN: Get a member by ID.
/// VI: Lấy thành viên theo ID.
/// </summary>
[HttpGet("members/{memberId:guid}")]
public Task<IActionResult> GetMemberById(Guid memberId) =>
_membership.GetAsync($"/api/v1/members/{memberId}").ProxyAsync();
/// <summary>
/// EN: Create a member.

View File

@@ -14,11 +14,13 @@ public class StaffController : ControllerBase
{
private readonly HttpClient _merchant;
private readonly HttpClient _booking;
private readonly HttpClient _iam;
public StaffController(IHttpClientFactory httpClientFactory)
{
_merchant = httpClientFactory.CreateClient("MerchantService");
_booking = httpClientFactory.CreateClient("BookingService");
_iam = httpClientFactory.CreateClient("IamService");
}
/// <summary>
@@ -37,6 +39,39 @@ public class StaffController : ControllerBase
public Task<IActionResult> CreateStaff([FromBody] JsonElement body) =>
_merchant.PostAsJsonAsync("/api/v1/merchants/me/staff", body).ProxyAsync();
/// <summary>
/// EN: Create IAM account + invite staff in one call.
/// VI: Tạo tài khoản IAM + mời nhân viên trong một lần gọi.
/// </summary>
[HttpPost("staff/invite-with-account")]
public async Task<IActionResult> InviteStaffWithAccount([FromBody] JsonElement body)
{
// Step 1: Create IAM account
var iamPayload = new
{
email = body.TryGetProperty("email", out var e) ? e.GetString() : "",
password = body.TryGetProperty("password", out var pw) ? pw.GetString() : "",
firstName = body.TryGetProperty("firstName", out var fn) ? fn.GetString() : "",
lastName = body.TryGetProperty("lastName", out var ln) ? ln.GetString() : ""
};
var iamResponse = await _iam.PostAsJsonAsync("/api/v1/auth/register", iamPayload);
if (!iamResponse.IsSuccessStatusCode)
{
var err = await iamResponse.Content.ReadAsStringAsync();
return StatusCode((int)iamResponse.StatusCode,
new { success = false, message = "IAM account creation failed", details = err });
}
// Step 2: Invite staff via MerchantService
var invitePayload = new
{
email = iamPayload.email,
role = body.TryGetProperty("role", out var r) ? r.GetString() : "Cashier",
shopId = body.TryGetProperty("shopId", out var s) ? s.GetString() : null as string
};
return await _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/invite", invitePayload).ProxyAsync();
}
/// <summary>
/// EN: Update a staff member.
/// VI: Cập nhật nhân viên.

View File

@@ -116,6 +116,7 @@ AddServiceClient("WalletService", "WalletService__BaseUrl", "http://loca
AddServiceClient("PromotionService", "PromotionService__BaseUrl", "http://localhost:5008");
AddServiceClient("BookingService", "BookingService__BaseUrl", "http://localhost:5020");
AddServiceClient("FnbEngine", "FnbEngine__BaseUrl", "http://localhost:5019");
AddServiceClient("IamService", "IamService__BaseUrl", "http://localhost:5001");
var app = builder.Build();

View File

@@ -58,4 +58,10 @@ public record CreateProductCommand : IRequest<Guid>
/// VI: URL hình ảnh sản phẩm.
/// </summary>
public string? ImageUrl { get; init; }
/// <summary>
/// EN: Category ID for product classification.
/// VI: ID danh mục để phân loại sản phẩm.
/// </summary>
public Guid? CategoryId { get; init; }
}

View File

@@ -45,7 +45,8 @@ public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand,
type: productType,
description: request.Description,
attributes: attributesJson,
sku: request.Sku);
sku: request.Sku,
categoryId: request.CategoryId);
// EN: Update image if provided
// VI: Cập nhật image nếu được cung cấp

View File

@@ -46,4 +46,10 @@ public record UpdateProductCommand : IRequest<bool>
/// VI: URL hình ảnh sản phẩm.
/// </summary>
public string? ImageUrl { get; init; }
/// <summary>
/// EN: Category ID for product classification.
/// VI: ID danh mục để phân loại sản phẩm.
/// </summary>
public Guid? CategoryId { get; init; }
}

View File

@@ -36,6 +36,10 @@ public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand,
// VI: Cập nhật thông tin cơ bản
product.UpdateInfo(request.Name, request.Description, request.Price);
// EN: Update category
// VI: Cập nhật danh mục
product.SetCategory(request.CategoryId);
// EN: Update attributes if provided
// VI: Cập nhật attributes nếu được cung cấp
if (request.Attributes != null)

View File

@@ -63,6 +63,18 @@ public record ProductDto
/// </summary>
public string? Sku { get; init; }
/// <summary>
/// EN: Category ID.
/// VI: ID danh mục.
/// </summary>
public Guid? CategoryId { get; init; }
/// <summary>
/// EN: Category name (resolved from lookup).
/// VI: Tên danh mục (resolve từ lookup).
/// </summary>
public string? CategoryName { get; init; }
/// <summary>
/// EN: Is product active.
/// VI: Sản phẩm có đang hoạt động không.

View File

@@ -33,6 +33,15 @@ public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, P
if (entity == null) return null;
// EN: Resolve category name if categoryId exists
// VI: Resolve tên danh mục nếu có categoryId
string? categoryName = null;
if (entity.CategoryId.HasValue)
{
var cat = await _context.Categories.FirstOrDefaultAsync(c => c.Id == entity.CategoryId.Value, cancellationToken);
categoryName = cat?.Name;
}
return new ProductDto
{
Id = entity.Id,
@@ -46,6 +55,8 @@ public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, P
: null,
ImageUrl = entity.ImageUrl,
Sku = entity.Sku,
CategoryId = entity.CategoryId,
CategoryName = categoryName,
IsActive = entity.IsActive,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt

View File

@@ -30,6 +30,12 @@ public record GetProductsQuery : IRequest<PagedResult<ProductDto>>
/// </summary>
public string? Type { get; init; }
/// <summary>
/// EN: Filter by category ID (null = all).
/// VI: Lọc theo danh mục (null = tất cả).
/// </summary>
public Guid? CategoryId { get; init; }
/// <summary>
/// EN: Page number (1-indexed).
/// VI: Số trang (bắt đầu từ 1).

View File

@@ -44,6 +44,11 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
query = query.Where(p => p.TypeId == typeEnum.Id);
}
if (request.CategoryId.HasValue)
{
query = query.Where(p => p.CategoryId == request.CategoryId.Value);
}
// EN: Get total count
// VI: Lấy tổng số
var totalCount = await query.CountAsync(cancellationToken);
@@ -62,6 +67,14 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
// VI: Build lookup TypeId → Name từ Enumeration.
var typeMap = Enumeration.GetAll<ProductType>().ToDictionary(t => t.Id, t => t.Name);
// EN: Build CategoryId → Name lookup.
// VI: Build lookup CategoryId → Name.
var categoryIds = entities.Where(p => p.CategoryId.HasValue).Select(p => p.CategoryId!.Value).Distinct().ToList();
var categoryMap = categoryIds.Count > 0
? await _context.Categories.Where(c => categoryIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken)
: new Dictionary<Guid, string>();
var products = entities.Select(p => new ProductDto
{
Id = p.Id,
@@ -75,6 +88,8 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
: null,
ImageUrl = p.ImageUrl,
Sku = p.Sku,
CategoryId = p.CategoryId,
CategoryName = p.CategoryId.HasValue && categoryMap.TryGetValue(p.CategoryId.Value, out var catName) ? catName : null,
IsActive = p.IsActive,
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt

View File

@@ -40,6 +40,7 @@ public class ProductsController : ControllerBase
[FromQuery] Guid shopId,
[FromQuery] bool? isActive = null,
[FromQuery] string? type = null,
[FromQuery] Guid? categoryId = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
@@ -49,6 +50,7 @@ public class ProductsController : ControllerBase
ShopId = shopId,
IsActive = isActive,
Type = type,
CategoryId = categoryId,
Page = page,
PageSize = pageSize
};
@@ -67,6 +69,7 @@ public class ProductsController : ControllerBase
Guid shopId,
[FromQuery] bool? isActive = null,
[FromQuery] string? type = null,
[FromQuery] Guid? categoryId = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
@@ -76,6 +79,7 @@ public class ProductsController : ControllerBase
ShopId = shopId,
IsActive = isActive,
Type = type,
CategoryId = categoryId,
Page = page,
PageSize = pageSize
};

View File

@@ -21,6 +21,7 @@ public class Product : Entity, IAggregateRoot
private JsonDocument? _attributes; // Type-specific attributes in JSONB
private string? _imageUrl;
private string? _sku;
private Guid? _categoryId;
private bool _isActive;
private DateTime _createdAt;
private DateTime? _updatedAt;
@@ -73,6 +74,12 @@ public class Product : Entity, IAggregateRoot
/// </summary>
public string? Sku => _sku;
/// <summary>
/// EN: Category ID for product classification.
/// VI: ID danh mục để phân loại sản phẩm.
/// </summary>
public Guid? CategoryId => _categoryId;
/// <summary>
/// EN: Is product active and available for sale.
/// VI: Sản phẩm có đang hoạt động và sẵn sàng bán không.
@@ -110,7 +117,8 @@ public class Product : Entity, IAggregateRoot
ProductType type,
string? description = null,
JsonDocument? attributes = null,
string? sku = null)
string? sku = null,
Guid? categoryId = null)
{
if (shopId == Guid.Empty)
throw new DomainException("Shop ID cannot be empty");
@@ -128,6 +136,7 @@ public class Product : Entity, IAggregateRoot
TypeId = type.Id;
_attributes = attributes;
_sku = sku?.Trim();
_categoryId = categoryId;
_isActive = true;
_createdAt = DateTime.UtcNow;
@@ -184,6 +193,16 @@ public class Product : Entity, IAggregateRoot
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Set category for product.
/// VI: Đặt danh mục cho sản phẩm.
/// </summary>
public void SetCategory(Guid? categoryId)
{
_categoryId = categoryId;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Deactivate product.
/// VI: Vô hiệu hóa sản phẩm.

View File

@@ -77,6 +77,10 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration<Product>
.HasColumnName("sku")
.HasMaxLength(100);
builder.Property(p => p.CategoryId)
.HasField("_categoryId")
.HasColumnName("category_id");
builder.Property(p => p.IsActive)
.HasField("_isActive")
.HasColumnName("is_active")
@@ -97,6 +101,7 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration<Product>
builder.HasIndex(p => p.TypeId).HasDatabaseName("ix_products_type_id");
builder.HasIndex(p => p.Sku).HasDatabaseName("ix_products_sku");
builder.HasIndex(p => p.IsActive).HasDatabaseName("ix_products_is_active");
builder.HasIndex(p => p.CategoryId).HasDatabaseName("ix_products_category_id");
// EN: TypeId is a plain FK column — no navigation property to ProductType.
// Type name is resolved from Enumeration in query handlers.

View File

@@ -0,0 +1,62 @@
using MediatR;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
namespace FnbEngine.API.Application.Commands;
public class CreateRecipeCommandHandler : IRequestHandler<CreateRecipeCommand, Guid>
{
private readonly IRecipeRepository _repo;
public CreateRecipeCommandHandler(IRecipeRepository repo) => _repo = repo;
public async Task<Guid> Handle(CreateRecipeCommand req, CancellationToken ct)
{
var recipe = new Recipe(req.ShopId, req.ProductId, req.Name, req.Instructions, req.PrepTimeMinutes);
if (req.Ingredients != null)
{
foreach (var ing in req.Ingredients)
recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit);
}
_repo.Add(recipe);
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
return recipe.Id;
}
}
public class UpdateRecipeCommandHandler : IRequestHandler<UpdateRecipeCommand, bool>
{
private readonly IRecipeRepository _repo;
public UpdateRecipeCommandHandler(IRecipeRepository repo) => _repo = repo;
public async Task<bool> Handle(UpdateRecipeCommand req, CancellationToken ct)
{
var recipe = await _repo.GetByIdAsync(req.RecipeId, ct);
if (recipe == null) return false;
recipe.Update(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);
}
_repo.Update(recipe);
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
return true;
}
}
public class DeleteRecipeCommandHandler : IRequestHandler<DeleteRecipeCommand, bool>
{
private readonly IRecipeRepository _repo;
public DeleteRecipeCommandHandler(IRecipeRepository repo) => _repo = repo;
public async Task<bool> Handle(DeleteRecipeCommand req, CancellationToken ct)
{
var recipe = await _repo.GetByIdAsync(req.RecipeId, ct);
if (recipe == null) return false;
recipe.Deactivate();
_repo.Update(recipe);
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
return true;
}
}

View File

@@ -0,0 +1,28 @@
using MediatR;
namespace FnbEngine.API.Application.Commands;
public record CreateRecipeCommand : IRequest<Guid>
{
public Guid ShopId { get; init; }
public Guid ProductId { get; init; }
public string Name { get; init; } = "";
public string? Instructions { get; init; }
public int PrepTimeMinutes { get; init; }
public List<IngredientItem>? Ingredients { get; init; }
}
public record UpdateRecipeCommand : IRequest<bool>
{
public Guid RecipeId { get; init; }
public Guid ShopId { get; init; }
public Guid ProductId { get; init; }
public string Name { get; init; } = "";
public string? Instructions { get; init; }
public int PrepTimeMinutes { get; init; }
public List<IngredientItem>? Ingredients { get; init; }
}
public record DeleteRecipeCommand(Guid RecipeId) : IRequest<bool>;
public record IngredientItem(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);

View File

@@ -0,0 +1,26 @@
using MediatR;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
namespace FnbEngine.API.Application.Queries;
public record GetRecipesByShopQuery(Guid ShopId) : IRequest<IEnumerable<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);
public class GetRecipesByShopQueryHandler : IRequestHandler<GetRecipesByShopQuery, IEnumerable<RecipeDto>>
{
private readonly IRecipeRepository _repo;
public GetRecipesByShopQueryHandler(IRecipeRepository repo) => _repo = repo;
public async Task<IEnumerable<RecipeDto>> Handle(GetRecipesByShopQuery req, CancellationToken ct)
{
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()
));
}
}

View File

@@ -62,6 +62,43 @@ public class KitchenController : ControllerBase
var result = await _mediator.Send(new UpdateTicketStatusCommand(id, request.Status), ct);
return Ok(new ApiResponse<bool> { Success = true, Data = result });
}
// ═══ RECIPE ENDPOINTS ═══
[HttpGet("recipes")]
[ProducesResponseType(typeof(ApiResponse<IEnumerable<RecipeDto>>), 200)]
public async Task<ActionResult<ApiResponse<IEnumerable<RecipeDto>>>> GetRecipes(
[FromQuery] Guid shopId, CancellationToken ct = default)
{
var result = await _mediator.Send(new GetRecipesByShopQuery(shopId), ct);
return Ok(new ApiResponse<IEnumerable<RecipeDto>> { Success = true, Data = result });
}
[HttpPost("recipes")]
[ProducesResponseType(typeof(ApiResponse<Guid>), 200)]
public async Task<ActionResult<ApiResponse<Guid>>> CreateRecipe(
[FromBody] CreateRecipeCommand command, CancellationToken ct = default)
{
var id = await _mediator.Send(command, ct);
return Ok(new ApiResponse<Guid> { Success = true, Data = id });
}
[HttpPut("recipes/{id}")]
[ProducesResponseType(typeof(ApiResponse<bool>), 200)]
public async Task<ActionResult<ApiResponse<bool>>> UpdateRecipe(
Guid id, [FromBody] UpdateRecipeCommand command, CancellationToken ct = default)
{
var cmd = command with { RecipeId = id };
var result = await _mediator.Send(cmd, ct);
return Ok(new ApiResponse<bool> { Success = true, Data = result });
}
[HttpDelete("recipes/{id}")]
[ProducesResponseType(typeof(ApiResponse<bool>), 200)]
public async Task<ActionResult<ApiResponse<bool>>> DeleteRecipe(Guid id, CancellationToken ct = default)
{
var result = await _mediator.Send(new DeleteRecipeCommand(id), ct);
return Ok(new ApiResponse<bool> { Success = true, Data = result });
}
}
public record UpdateStatusRequest(string Status);

View File

@@ -0,0 +1,12 @@
using FnbEngine.Domain.SeedWork;
namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate;
public interface IRecipeRepository : IRepository<Recipe>
{
Recipe Add(Recipe recipe);
void Update(Recipe recipe);
void Delete(Recipe recipe);
Task<Recipe?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IEnumerable<Recipe>> GetByShopIdAsync(Guid shopId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,65 @@
using FnbEngine.Domain.SeedWork;
namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate;
public class Recipe : Entity, IAggregateRoot
{
private Guid _shopId;
private Guid _productId;
private string _name = null!;
private string? _instructions;
private int _prepTimeMinutes;
private bool _isActive;
private DateTime _createdAt;
private DateTime? _updatedAt;
private readonly List<RecipeIngredient> _ingredients = new();
public Guid ShopId => _shopId;
public Guid ProductId => _productId;
public string Name => _name;
public string? Instructions => _instructions;
public int PrepTimeMinutes => _prepTimeMinutes;
public bool IsActive => _isActive;
public DateTime CreatedAt => _createdAt;
public DateTime? UpdatedAt => _updatedAt;
public IReadOnlyList<RecipeIngredient> Ingredients => _ingredients.AsReadOnly();
protected Recipe() { }
public Recipe(Guid shopId, Guid productId, string name, string? instructions, int prepTimeMinutes)
{
Id = Guid.NewGuid();
_shopId = shopId;
_productId = productId;
_name = name;
_instructions = instructions;
_prepTimeMinutes = prepTimeMinutes;
_isActive = true;
_createdAt = DateTime.UtcNow;
}
public void Update(string name, string? instructions, int prepTimeMinutes)
{
_name = name;
_instructions = instructions;
_prepTimeMinutes = prepTimeMinutes;
_updatedAt = DateTime.UtcNow;
}
public void AddIngredient(string ingredientName, decimal quantity, string unit, decimal costPerUnit)
{
_ingredients.Add(new RecipeIngredient(Id, ingredientName, quantity, unit, costPerUnit));
}
public void ClearIngredients()
{
_ingredients.Clear();
_updatedAt = DateTime.UtcNow;
}
public void Deactivate()
{
_isActive = false;
_updatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,30 @@
using FnbEngine.Domain.SeedWork;
namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate;
public class RecipeIngredient : Entity
{
private Guid _recipeId;
private string _ingredientName = null!;
private decimal _quantity;
private string _unit = null!;
private decimal _costPerUnit;
public Guid RecipeId => _recipeId;
public string IngredientName => _ingredientName;
public decimal Quantity => _quantity;
public string Unit => _unit;
public decimal CostPerUnit => _costPerUnit;
protected RecipeIngredient() { }
public RecipeIngredient(Guid recipeId, string ingredientName, decimal quantity, string unit, decimal costPerUnit)
{
Id = Guid.NewGuid();
_recipeId = recipeId;
_ingredientName = ingredientName;
_quantity = quantity;
_unit = unit;
_costPerUnit = costPerUnit;
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using FnbEngine.Domain.AggregatesModel.TableAggregate;
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
using FnbEngine.Infrastructure.Idempotency;
using FnbEngine.Infrastructure.Repositories;
@@ -52,6 +53,7 @@ public static class DependencyInjection
services.AddScoped<ITableRepository, TableRepository>();
services.AddScoped<ISessionRepository, SessionRepository>();
services.AddScoped<IKitchenTicketRepository, KitchenTicketRepository>();
services.AddScoped<IRecipeRepository, RecipeRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
namespace FnbEngine.Infrastructure.EntityConfigurations;
public class RecipeEntityTypeConfiguration : IEntityTypeConfiguration<Recipe>
{
public void Configure(EntityTypeBuilder<Recipe> builder)
{
builder.ToTable("recipes");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(r => r.ShopId).HasField("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property(r => r.ProductId).HasField("_productId").HasColumnName("product_id").IsRequired();
builder.Property(r => r.Name).HasField("_name").HasColumnName("name").HasMaxLength(255).IsRequired();
builder.Property(r => r.Instructions).HasField("_instructions").HasColumnName("instructions").HasMaxLength(2000);
builder.Property(r => r.PrepTimeMinutes).HasField("_prepTimeMinutes").HasColumnName("prep_time_minutes");
builder.Property(r => r.IsActive).HasField("_isActive").HasColumnName("is_active").HasDefaultValue(true);
builder.Property(r => r.CreatedAt).HasField("_createdAt").HasColumnName("created_at").IsRequired();
builder.Property(r => r.UpdatedAt).HasField("_updatedAt").HasColumnName("updated_at");
builder.HasMany(r => r.Ingredients)
.WithOne()
.HasForeignKey(i => i.RecipeId)
.OnDelete(DeleteBehavior.Cascade);
var nav = builder.Metadata.FindNavigation(nameof(Recipe.Ingredients))!;
nav.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasIndex(r => r.ShopId).HasDatabaseName("ix_recipes_shop_id");
builder.HasIndex(r => r.ProductId).HasDatabaseName("ix_recipes_product_id");
builder.Ignore(r => r.DomainEvents);
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
namespace FnbEngine.Infrastructure.EntityConfigurations;
public class RecipeIngredientEntityTypeConfiguration : IEntityTypeConfiguration<RecipeIngredient>
{
public void Configure(EntityTypeBuilder<RecipeIngredient> builder)
{
builder.ToTable("recipe_ingredients");
builder.HasKey(ri => ri.Id);
builder.Property(ri => ri.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(ri => ri.RecipeId).HasField("_recipeId").HasColumnName("recipe_id").IsRequired();
builder.Property(ri => ri.IngredientName).HasField("_ingredientName").HasColumnName("ingredient_name").HasMaxLength(200).IsRequired();
builder.Property(ri => ri.Quantity).HasField("_quantity").HasColumnName("quantity").HasColumnType("decimal(18,4)").IsRequired();
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)");
builder.Ignore(ri => ri.DomainEvents);
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Storage;
using FnbEngine.Domain.AggregatesModel.TableAggregate;
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
using FnbEngine.Domain.SeedWork;
using FnbEngine.Infrastructure.EntityConfigurations;
@@ -17,6 +18,8 @@ public class FnbContext : DbContext, IUnitOfWork
public DbSet<Table> Tables => Set<Table>();
public DbSet<Session> Sessions => Set<Session>();
public DbSet<KitchenTicket> KitchenTickets => Set<KitchenTicket>();
public DbSet<Recipe> Recipes => Set<Recipe>();
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
public bool HasActiveTransaction => _currentTransaction != null;
@@ -31,6 +34,8 @@ public class FnbContext : DbContext, IUnitOfWork
modelBuilder.ApplyConfiguration(new TableStatusEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new SessionEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new KitchenTicketEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new RecipeEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new RecipeIngredientEntityTypeConfiguration());
}
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
using FnbEngine.Domain.SeedWork;
namespace FnbEngine.Infrastructure.Repositories;
public class RecipeRepository : IRecipeRepository
{
private readonly FnbContext _context;
public IUnitOfWork UnitOfWork => _context;
public RecipeRepository(FnbContext context) => _context = context;
public Recipe Add(Recipe recipe) => _context.Recipes.Add(recipe).Entity;
public void Update(Recipe recipe) => _context.Recipes.Update(recipe);
public void Delete(Recipe recipe) => _context.Recipes.Remove(recipe);
public async Task<Recipe?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
await _context.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == id, ct);
public async Task<IEnumerable<Recipe>> GetByShopIdAsync(Guid shopId, CancellationToken ct = default) =>
await _context.Recipes.Include(r => r.Ingredients)
.Where(r => r.ShopId == shopId && r.IsActive)
.OrderBy(r => r.Name)
.ToListAsync(ct);
}

View File

@@ -25,6 +25,18 @@ public class CreateMemberCommand : IRequest<CreateMemberResult>
/// VI: Giới tính (tùy chọn).
/// </summary>
public string? Gender { get; set; }
/// <summary>
/// EN: Display name (optional, stored in preferences).
/// VI: Tên hiển thị (tùy chọn, lưu trong preferences).
/// </summary>
public string? Name { get; set; }
/// <summary>
/// EN: Phone number (optional, stored in preferences).
/// VI: Số điện thoại (tùy chọn, lưu trong preferences).
/// </summary>
public string? Phone { get; set; }
}
/// <summary>

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using MediatR;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
@@ -34,6 +35,16 @@ public class CreateMemberCommandHandler : IRequestHandler<CreateMemberCommand, C
// VI: Tạo member mới với gender (bắt đầu ở level 1, exp 0)
var member = new Member(request.UserId, request.CountryCode, request.Gender);
// EN: Store name/phone in preferences JSON if provided
// VI: Lưu name/phone vào preferences JSON nếu có
if (!string.IsNullOrWhiteSpace(request.Name) || !string.IsNullOrWhiteSpace(request.Phone))
{
var prefs = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(request.Name)) prefs["name"] = request.Name;
if (!string.IsNullOrWhiteSpace(request.Phone)) prefs["phone"] = request.Phone;
member.UpdatePreferences(JsonSerializer.Serialize(prefs));
}
_memberRepository.Add(member);
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);

View File

@@ -31,6 +31,8 @@ public class MemberDto
public string? Gender { get; set; }
public string CountryCode { get; set; } = null!;
public string? Preferences { get; set; }
public string? DisplayName { get; set; }
public string? Phone { get; set; }
/// <summary>
/// EN: Current member level (1, 2, 3...).

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using MediatR;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
@@ -29,6 +30,17 @@ public class GetMemberByIdQueryHandler : IRequestHandler<GetMemberByIdQuery, Mem
private static MemberDto MapToDto(Member member)
{
string? displayName = null, phone = null;
if (!string.IsNullOrWhiteSpace(member.Preferences))
{
try
{
var prefs = JsonSerializer.Deserialize<JsonElement>(member.Preferences);
if (prefs.TryGetProperty("name", out var n)) displayName = n.GetString();
if (prefs.TryGetProperty("phone", out var p)) phone = p.GetString();
}
catch { }
}
return new MemberDto
{
Id = member.Id,
@@ -36,6 +48,8 @@ public class GetMemberByIdQueryHandler : IRequestHandler<GetMemberByIdQuery, Mem
Gender = member.Gender,
CountryCode = member.CountryCode,
Preferences = member.Preferences,
DisplayName = displayName,
Phone = phone,
CurrentLevel = member.CurrentLevel,
CurrentExp = member.CurrentExp,
TotalExpEarned = member.TotalExpEarned,

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using MediatR;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
@@ -24,18 +25,34 @@ public class GetMembersQueryHandler : IRequestHandler<GetMembersQuery, GetMember
request.SearchTerm,
cancellationToken);
var memberDtos = members.Select(m => new MemberDto
var memberDtos = members.Select(m =>
{
Id = m.Id,
UserId = m.UserId,
Gender = m.Gender,
CountryCode = m.CountryCode,
Preferences = m.Preferences,
CurrentLevel = m.CurrentLevel,
CurrentExp = m.CurrentExp,
TotalExpEarned = m.TotalExpEarned,
CreatedAt = m.CreatedAt,
UpdatedAt = m.UpdatedAt
string? displayName = null, phone = null;
if (!string.IsNullOrWhiteSpace(m.Preferences))
{
try
{
var prefs = JsonSerializer.Deserialize<JsonElement>(m.Preferences);
if (prefs.TryGetProperty("name", out var n)) displayName = n.GetString();
if (prefs.TryGetProperty("phone", out var p)) phone = p.GetString();
}
catch { }
}
return new MemberDto
{
Id = m.Id,
UserId = m.UserId,
Gender = m.Gender,
CountryCode = m.CountryCode,
Preferences = m.Preferences,
DisplayName = displayName,
Phone = phone,
CurrentLevel = m.CurrentLevel,
CurrentExp = m.CurrentExp,
TotalExpEarned = m.TotalExpEarned,
CreatedAt = m.CreatedAt,
UpdatedAt = m.UpdatedAt
};
});
return new GetMembersResult

View File

@@ -12,7 +12,10 @@ namespace OrderService.API.Application.Commands;
public record CreateOrderCommand(
Guid ShopId,
Guid? CustomerId,
List<OrderItemRequest> Items
List<OrderItemRequest> Items,
decimal? DiscountAmount = null,
string? DiscountType = null,
string? DiscountReference = null
) : IRequest<CreateOrderResult>;
/// <summary>

View File

@@ -67,6 +67,13 @@ public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Cre
}
}
// EN: Apply discount if provided
// VI: Áp dụng giảm giá nếu có
if (request.DiscountAmount is > 0)
{
order.ApplyDiscount(request.DiscountAmount.Value, request.DiscountType, request.DiscountReference);
}
// EN: Mark order as validated after all items pass validation
// VI: Đánh dấu order là validated sau khi tất cả items pass validation
order.MarkAsValidated();

View File

@@ -20,6 +20,10 @@ public class Order : Entity, IAggregateRoot
private DateTime _createdAt;
private DateTime? _updatedAt;
private decimal _discountAmount;
private string? _discountType;
private string? _discountReference;
private readonly List<OrderItem> _items = new();
/// <summary>
@@ -56,6 +60,10 @@ public class Order : Entity, IAggregateRoot
/// EN: Order items (line items).
/// VI: Các items trong đơn hàng (dòng hàng).
/// </summary>
public decimal DiscountAmount => _discountAmount;
public string? DiscountType => _discountType;
public string? DiscountReference => _discountReference;
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
/// <summary>
@@ -192,8 +200,23 @@ public class Order : Entity, IAggregateRoot
AddDomainEvent(new OrderCancelledDomainEvent(this, reason));
}
/// <summary>
/// EN: Apply discount to order.
/// VI: Áp dụng giảm giá cho đơn hàng.
/// </summary>
public void ApplyDiscount(decimal amount, string? type = null, string? reference = null)
{
if (amount < 0) throw new DomainException("Discount amount cannot be negative");
_discountAmount = amount;
_discountType = type;
_discountReference = reference;
RecalculateTotal();
_updatedAt = DateTime.UtcNow;
}
private void RecalculateTotal()
{
_totalAmount = _items.Sum(i => i.TotalPrice);
_totalAmount = _items.Sum(i => i.TotalPrice) - _discountAmount;
if (_totalAmount < 0) _totalAmount = 0;
}
}

View File

@@ -50,6 +50,18 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
builder.Property<decimal>("_discountAmount")
.HasColumnName("discount_amount")
.HasColumnType("decimal(18,2)");
builder.Property<string?>("_discountType")
.HasColumnName("discount_type")
.HasMaxLength(50);
builder.Property<string?>("_discountReference")
.HasColumnName("discount_reference")
.HasMaxLength(255);
// EN: OrderItems collection
// VI: Collection OrderItems
builder.OwnsMany(o => o.Items, orderItems =>
@@ -122,6 +134,9 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
builder.Ignore(o => o.CustomerId);
builder.Ignore(o => o.Status);
builder.Ignore(o => o.TotalAmount);
builder.Ignore(o => o.DiscountAmount);
builder.Ignore(o => o.DiscountType);
builder.Ignore(o => o.DiscountReference);
builder.Ignore(o => o.CreatedAt);
builder.Ignore(o => o.UpdatedAt);
}