Files
pos-system/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
Ho Ngoc Hai 14d6c4012c feat(web-client-tpos): Phase A — categories CRUD, order management, shop update, reports
BFF Endpoints (6 new):
- POST/PUT/DELETE categories — full CRUD with shop ownership validation
- GET orders/{id} — order detail with items
- PUT orders/{id}/cancel — cancel non-completed orders (status=6)
- PUT shops/{id} — update name, phone, email, hours
- GET reports/revenue — daily/weekly/monthly revenue aggregation

PosDataService (8 new methods):
- CreateCategory, UpdateCategory, DeleteCategory
- GetOrderDetail, CancelOrder
- UpdateShop
- GetRevenueReport

ShopPage UI (222 lines):
- Menu tab: categories table with add/edit/delete
- Finance tab: expandable order rows with items + cancel button
- Overview tab: shop info edit form
- Reports tab: period selector (Ngày/Tuần/Tháng) + revenue table
2026-03-03 21:22:25 +07:00

381 lines
18 KiB
C#

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace WebClientTpos.Client.Services;
// EN: POS data service — attaches auth token for multi-tenant data isolation.
// VI: POS data service — đính kèm auth token để cách ly dữ liệu multi-tenant.
public class PosDataService
{
private readonly HttpClient _http;
private readonly AuthStateService _authState;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public PosDataService(HttpClient http, AuthStateService authState)
{
_http = http;
_authState = authState;
}
/// <summary>
/// EN: Ensure Authorization header is set before each BFF call.
/// VI: Đảm bảo header Authorization được đặt trước mỗi BFF call.
/// </summary>
private void AttachToken()
{
if (!string.IsNullOrEmpty(_authState.Token))
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _authState.Token);
}
}
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 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);
public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName);
public async Task<List<ShopInfo>> GetShopsAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<ShopInfo>>("api/bff/shops", _jsonOptions) ?? new(); }
public async Task<ShopInfo?> GetShopByIdAsync(Guid shopId)
{ AttachToken(); return await _http.GetFromJsonAsync<ShopInfo>($"api/bff/shops/{shopId}", _jsonOptions); }
public async Task<List<ProductInfo>> GetProductsAsync(Guid shopId)
{ AttachToken(); return await _http.GetFromJsonAsync<List<ProductInfo>>($"api/bff/shops/{shopId}/products", _jsonOptions) ?? new(); }
public async Task<List<CategoryInfo>> GetCategoriesAsync(Guid shopId)
{ AttachToken(); return await _http.GetFromJsonAsync<List<CategoryInfo>>($"api/bff/shops/{shopId}/categories", _jsonOptions) ?? new(); }
public async Task<List<TableInfo>> GetTablesAsync(Guid shopId)
{ AttachToken(); return await _http.GetFromJsonAsync<List<TableInfo>>($"api/bff/shops/{shopId}/tables", _jsonOptions) ?? new(); }
public async Task<List<AppointmentInfo>> GetAppointmentsAsync(Guid shopId)
{ AttachToken(); return await _http.GetFromJsonAsync<List<AppointmentInfo>>($"api/bff/shops/{shopId}/appointments", _jsonOptions) ?? new(); }
public async Task<List<StaffInfo>> GetStaffAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<StaffInfo>>("api/bff/staff", _jsonOptions) ?? new(); }
// ═══ ADMIN-LEVEL PRODUCT/CATEGORY METHODS ═══
// 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);
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);
public async Task<List<AdminProductInfo>> GetAllProductsAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/products?shopId={shopId}" : "api/bff/products";
return await _http.GetFromJsonAsync<List<AdminProductInfo>>(url, _jsonOptions) ?? new();
}
public async Task<List<AdminCategoryInfo>> GetAllCategoriesAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/categories?shopId={shopId}" : "api/bff/categories";
return await _http.GetFromJsonAsync<List<AdminCategoryInfo>>(url, _jsonOptions) ?? new();
}
public async Task<bool> CreateProductAsync(CreateProductRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/products", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateProductAsync(Guid productId, CreateProductRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/products/{productId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteProductAsync(Guid productId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/products/{productId}");
return resp.IsSuccessStatusCode;
}
// ═══ INVENTORY METHODS ═══
public record InventoryItemInfo(Guid Id, Guid ProductId, Guid ShopId, int Quantity,
int ReorderLevel, int ReservedQuantity, DateTime? UpdatedAt, string? ProductName);
public async Task<List<InventoryItemInfo>> GetInventoryAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/inventory?shopId={shopId}" : "api/bff/inventory";
return await _http.GetFromJsonAsync<List<InventoryItemInfo>>(url, _jsonOptions) ?? new();
}
// ═══ MEMBERSHIP/CUSTOMER METHODS ═══
public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp,
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName);
public async Task<List<MemberInfo>> GetMembersAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<MemberInfo>>("api/bff/members", _jsonOptions) ?? new(); }
// ═══ STAFF CREATE ═══
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
public async Task<bool> CreateStaffAsync(CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateStaffAsync(Guid staffId, CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/staff/{staffId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteStaffAsync(Guid staffId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/staff/{staffId}");
return resp.IsSuccessStatusCode;
}
public record UpdateInventoryRequest(int Quantity, int ReorderLevel);
public async Task<bool> UpdateInventoryAsync(Guid inventoryId, UpdateInventoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/inventory/{inventoryId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
// ═══ STAFF ROLES & SCHEDULES ═══
public record StaffRoleInfo(int Id, string Name);
public record ScheduleInfo(Guid Id, Guid StaffId, Guid ShopId, int DayOfWeek, string StartTime, string EndTime,
string? EmployeeCode, string? Role, string? Phone);
public async Task<List<StaffRoleInfo>> GetStaffRolesAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<StaffRoleInfo>>("api/bff/staff/roles", _jsonOptions) ?? new(); }
public async Task<List<ScheduleInfo>> GetStaffSchedulesAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/staff/schedules?shopId={shopId}" : "api/bff/staff/schedules";
return await _http.GetFromJsonAsync<List<ScheduleInfo>>(url, _jsonOptions) ?? new();
}
// ═══ ORDERS ═══
public record OrderInfo(Guid Id, Guid ShopId, decimal TotalAmount, int StatusId, DateTime CreatedAt,
string? Status, string? PaymentMethod, string? Notes);
public async Task<List<OrderInfo>> GetOrdersAsync(Guid? shopId = null, string filter = "today")
{
AttachToken();
var url = shopId.HasValue
? $"api/bff/orders?shopId={shopId}&filter={filter}"
: $"api/bff/orders?filter={filter}";
return await _http.GetFromJsonAsync<List<OrderInfo>>(url, _jsonOptions) ?? new();
}
// ═══ WALLETS / FINANCE ═══
public record WalletInfo(Guid Id, decimal Balance, string? Currency, Guid OwnerId, DateTime CreatedAt, decimal TotalIncome, decimal TotalExpense);
public record WalletTxnInfo(Guid Id, Guid WalletId, decimal Amount, string? Description, DateTime CreatedAt, string? ItemName);
public async Task<List<WalletInfo>> GetWalletsAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<WalletInfo>>("api/bff/wallets", _jsonOptions) ?? new(); }
public async Task<List<WalletTxnInfo>> GetWalletTransactionsAsync(int limit = 50)
{ AttachToken(); return await _http.GetFromJsonAsync<List<WalletTxnInfo>>($"api/bff/wallet/transactions?limit={limit}", _jsonOptions) ?? new(); }
// ═══ DEVICES ═══
public record DeviceInfo(Guid Id, string? DeviceToken, string? Platform, bool IsActive, DateTime CreatedAt, string? StaffCode);
public async Task<List<DeviceInfo>> GetDevicesAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<DeviceInfo>>("api/bff/devices", _jsonOptions) ?? new(); }
// ═══ PROMOTIONS ═══
public record PromotionInfo(Guid Id, string Name, string? Description, DateTime? StartDate, DateTime? EndDate,
bool IsActive, string? DiscountType, decimal? DiscountValue, int VoucherCount, int RedemptionCount);
public async Task<List<PromotionInfo>> GetPromotionsAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<PromotionInfo>>("api/bff/promotions", _jsonOptions) ?? new(); }
// ═══ INVENTORY TRANSACTIONS ═══
public record InventoryTxnInfo(Guid Id, Guid InventoryItemId, int QuantityChange, string? Reason, DateTime CreatedAt, string? TransactionType);
public async Task<List<InventoryTxnInfo>> GetInventoryTransactionsAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/inventory/transactions?shopId={shopId}" : "api/bff/inventory/transactions";
return await _http.GetFromJsonAsync<List<InventoryTxnInfo>>(url, _jsonOptions) ?? new();
}
// ═══ MEMBERSHIP LEVELS ═══
public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount);
public async Task<List<LevelDefinitionInfo>> GetMembershipLevelsAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<LevelDefinitionInfo>>("api/bff/membership/levels", _jsonOptions) ?? new(); }
// ═══ SHOP STATS (aggregated per-shop) ═══
public record ShopStatsInfo(Guid ShopId, int ProductCount, int OrderCount, int StaffCount, decimal Revenue);
public async Task<List<ShopStatsInfo>> GetShopStatsAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<ShopStatsInfo>>("api/bff/shops/stats", _jsonOptions) ?? new(); }
// ═══ BOOKING RESOURCES ═══
public record ResourceInfo(Guid Id, string Name, string? ResourceType, int Capacity, bool IsActive);
public async Task<List<ResourceInfo>> GetResourcesAsync(Guid shopId)
{ AttachToken(); return await _http.GetFromJsonAsync<List<ResourceInfo>>($"api/bff/shops/{shopId}/resources", _jsonOptions) ?? new(); }
// ═══ POS DASHBOARD (real-time daily stats) ═══
// EN: POS dashboard response DTOs
// VI: DTOs cho response dashboard POS
public record PosDashboardInfo(
decimal Revenue, int OrderCount, int ItemsSold, decimal AvgOrderValue,
List<PopularItemInfo> PopularItems,
List<PaymentBreakdownInfo> PaymentBreakdown,
List<HourlyRevenueInfo> HourlyRevenue,
List<RecentOrderInfo> RecentOrders);
public record PopularItemInfo(string Name, int Qty, decimal Revenue);
public record PaymentBreakdownInfo(string Method, decimal Amount, int Pct);
public record HourlyRevenueInfo(string Hour, decimal Revenue, int Pct);
public record RecentOrderInfo(string Id, decimal Total, string Time, string Status, string Method);
public async Task<PosDashboardInfo> GetPosDashboardAsync(Guid shopId)
{
AttachToken();
return await _http.GetFromJsonAsync<PosDashboardInfo>(
$"api/bff/pos/dashboard?shopId={shopId}", _jsonOptions)
?? new(0, 0, 0, 0, new(), new(), new(), new());
}
// ═══ POS ORDER CREATE ═══
// EN: POS order creation DTOs
// VI: DTOs cho tạo đơn POS
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items);
public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice);
public record CreatePosOrderResponse(Guid OrderId, string TransactionId, decimal TotalAmount, string Status);
public async Task<CreatePosOrderResponse?> CreatePosOrderAsync(CreatePosOrderRequest req)
{
AttachToken();
// EN: Use camelCase for POST body (ASP.NET model binding default)
// VI: Dùng camelCase cho POST body (ASP.NET model binding mặc định)
var postOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var resp = await _http.PostAsJsonAsync("api/bff/pos/orders", req, postOptions);
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<CreatePosOrderResponse>(_jsonOptions);
return null;
}
// ═══ CATEGORIES CRUD ═══
// EN: Category create/update request DTO
// VI: DTO tạo/cập nhật danh mục
public record AdminCreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder);
public async Task<bool> CreateCategoryAsync(AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCategoryAsync(Guid categoryId, AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteCategoryAsync(Guid categoryId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/categories/{categoryId}");
return resp.IsSuccessStatusCode;
}
// ═══ ORDER DETAIL & CANCEL ═══
// EN: Order detail DTOs
// VI: DTOs cho chi tiết đơn hàng
public record OrderDetailInfo(Guid Id, Guid ShopId, decimal TotalAmount, string? Status, int StatusId, string? PaymentMethod, string? Notes, DateTime CreatedAt);
public record OrderItemInfo(Guid Id, string? ProductName, int Quantity, decimal UnitPrice, decimal Subtotal);
public record OrderDetailResponse(OrderDetailInfo? Order, List<OrderItemInfo>? Items);
public async Task<OrderDetailResponse?> GetOrderDetailAsync(Guid orderId)
{
AttachToken();
return await _http.GetFromJsonAsync<OrderDetailResponse>($"api/bff/orders/{orderId}", _jsonOptions);
}
public async Task<bool> CancelOrderAsync(Guid orderId)
{
AttachToken();
using var req = new HttpRequestMessage(HttpMethod.Put, $"api/bff/orders/{orderId}/cancel");
req.Headers.Authorization = _http.DefaultRequestHeaders.Authorization;
var resp = await _http.SendAsync(req);
return resp.IsSuccessStatusCode;
}
// ═══ SHOP UPDATE ═══
// EN: Shop update DTO
// VI: DTO cập nhật thông tin cửa hàng
public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays);
public async Task<bool> UpdateShopAsync(Guid shopId, UpdateShopRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
// ═══ REVENUE REPORT ═══
// EN: Revenue report item DTO
// VI: DTO cho từng dòng báo cáo doanh thu
public record RevenueReportItem(DateTime Period, long OrderCount, decimal Revenue);
public async Task<List<RevenueReportItem>> GetRevenueReportAsync(string period = "daily", Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue
? $"api/bff/reports/revenue?period={period}&shopId={shopId}"
: $"api/bff/reports/revenue?period={period}";
return await _http.GetFromJsonAsync<List<RevenueReportItem>>(url, _jsonOptions) ?? new();
}
}