Files
pos-system/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs

1168 lines
53 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;
// EN: Read options — case-insensitive to handle camelCase responses from BFF/microservices.
// VI: Options đọc — không phân biệt hoa thường để xử lý camelCase responses từ BFF/microservices.
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// EN: Write options — camelCase to match ASP.NET model binding defaults
// VI: Options ghi — camelCase để khớp với ASP.NET model binding mặc định
private static readonly JsonSerializerOptions _writeOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
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);
}
}
private static async Task<string> TryExtractError(HttpResponseMessage resp)
{
if ((int)resp.StatusCode == 401)
return "Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.";
try
{
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("message", out var msg))
return msg.GetString() ?? resp.StatusCode.ToString();
if (doc.RootElement.TryGetProperty("detail", out var detail))
return detail.GetString() ?? resp.StatusCode.ToString();
if (doc.RootElement.TryGetProperty("title", out var title))
return title.GetString() ?? resp.StatusCode.ToString();
return body.Length > 200 ? $"Lỗi ({resp.StatusCode})" : body;
}
catch { return $"Lỗi ({resp.StatusCode})"; }
}
/// <summary>
/// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes.
/// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope.
/// </summary>
private async Task<List<T>> GetListFromApiAsync<T>(string url)
{
AttachToken();
var resp = await _http.GetAsync(url);
if (!resp.IsSuccessStatusCode) return new();
var json = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json)) return new();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Case 1: plain array [...]
if (root.ValueKind == JsonValueKind.Array)
return JsonSerializer.Deserialize<List<T>>(json, _jsonOptions) ?? new();
// Case 2: { "items": [...] } (PagedResult)
if (root.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array)
return JsonSerializer.Deserialize<List<T>>(items.GetRawText(), _jsonOptions) ?? new();
// Case 3: { "data": { "items": [...] } } (ApiResponse<PagedResult>)
// Case 4: { "data": [...] } (ApiResponse<List>)
if (root.TryGetProperty("data", out var data))
{
if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("items", out var dataItems) && dataItems.ValueKind == JsonValueKind.Array)
return JsonSerializer.Deserialize<List<T>>(dataItems.GetRawText(), _jsonOptions) ?? new();
if (data.ValueKind == JsonValueKind.Array)
return JsonSerializer.Deserialize<List<T>>(data.GetRawText(), _jsonOptions) ?? new();
}
// Case 5: { "<anyKey>": [...] } — generic single-array-property wrapper (e.g. { "members": [...] })
foreach (var prop in root.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.Array)
return JsonSerializer.Deserialize<List<T>>(prop.Value.GetRawText(), _jsonOptions) ?? new();
}
return new();
}
/// <summary>
/// EN: Robust single-object deserialization — handles plain objects and ApiResponse envelopes.
/// VI: Deserialize đối tượng đơn linh hoạt — xử lý object thuần và ApiResponse envelope.
/// </summary>
private async Task<T?> GetObjectFromApiAsync<T>(string url) where T : class
{
AttachToken();
var resp = await _http.GetAsync(url);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json)) return null;
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Case 1: { "data": {...} } (ApiResponse envelope)
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
return JsonSerializer.Deserialize<T>(data.GetRawText(), _jsonOptions);
// Case 2: plain object
if (root.ValueKind == JsonValueKind.Object)
return JsonSerializer.Deserialize<T>(json, _jsonOptions);
return null;
}
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? CategoryName, int? DurationMinutes, Guid? CategoryId = null)
{
public string? Category => CategoryName;
}
public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder, string? ImageUrl = null);
public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt, decimal HourlyRate = 0, int? PositionX = null, int? PositionY = null, string? QrToken = null);
public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName);
public record ShopAssignmentInfo(Guid ShopId, string? ShopRole, Guid? BranchId);
public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName,
string? FirstName = null, string? LastName = null, string? Address = null, string? ProfilePhotoUrl = null, string? DocumentFrontUrl = null, string? DocumentBackUrl = null,
List<ShopAssignmentInfo>? ShopAssignments = null);
public async Task<List<StaffInfo>> GetStaffForShopAsync(Guid shopId)
{
var all = await GetStaffAsync();
return all.Where(s =>
s.ShopAssignments == null || s.ShopAssignments.Count == 0 ||
s.ShopAssignments.Any(a => a.ShopId == shopId)).ToList();
}
public async Task<List<ShopInfo>> GetShopsAsync()
=> await GetListFromApiAsync<ShopInfo>("api/bff/shops");
public async Task<ShopInfo?> GetShopByIdAsync(Guid shopId)
=> await GetObjectFromApiAsync<ShopInfo>($"api/bff/shops/{shopId}");
public async Task<List<ProductInfo>> GetProductsAsync(Guid shopId)
=> await GetListFromApiAsync<ProductInfo>($"api/bff/shops/{shopId}/products");
public async Task<List<CategoryInfo>> GetCategoriesAsync(Guid shopId)
=> await GetListFromApiAsync<CategoryInfo>($"api/bff/shops/{shopId}/categories");
public async Task<List<TableInfo>> GetTablesAsync(Guid shopId)
=> await GetListFromApiAsync<TableInfo>($"api/bff/shops/{shopId}/tables");
public async Task<List<AppointmentInfo>> GetAppointmentsAsync(Guid shopId)
=> await GetListFromApiAsync<AppointmentInfo>($"api/bff/shops/{shopId}/appointments");
public async Task<List<StaffInfo>> GetStaffAsync()
=> await GetListFromApiAsync<StaffInfo>("api/bff/staff");
// ═══ 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, Guid? CategoryId = null);
public record AdminCategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder,
Guid ShopId, Guid? ParentId, bool IsActive, string? ImageUrl = null);
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price,
string? Type, string? Sku, string? ImageUrl, Guid? CategoryId = null);
public async Task<List<AdminProductInfo>> GetAllProductsAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/products?shopId={shopId}" : "api/bff/products";
return await GetListFromApiAsync<AdminProductInfo>(url);
}
public async Task<List<AdminCategoryInfo>> GetAllCategoriesAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/categories?shopId={shopId}" : "api/bff/categories";
return await GetListFromApiAsync<AdminCategoryInfo>(url);
}
public async Task<bool> CreateProductAsync(CreateProductRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/products", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateProductAsync(Guid productId, CreateProductRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/products/{productId}", req, _writeOptions);
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)
{
var url = shopId.HasValue ? $"api/bff/inventory?shopId={shopId}" : "api/bff/inventory";
return await GetListFromApiAsync<InventoryItemInfo>(url);
}
// ═══ MEMBERSHIP/CUSTOMER METHODS ═══
public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp,
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName,
string? DisplayName = null, string? Phone = null);
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 ═══
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role,
string? FirstName = null, string? LastName = null, string? Address = null,
string? ProfilePhotoUrl = null, string? DocumentFrontUrl = null, string? DocumentBackUrl = null);
public async Task<bool> CreateStaffAsync(CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateStaffAsync(Guid staffId, CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/staff/{staffId}", req, _writeOptions);
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, _writeOptions);
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()
=> await GetListFromApiAsync<StaffRoleInfo>("api/bff/staff/roles");
public async Task<List<ScheduleInfo>> GetStaffSchedulesAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/staff/schedules?shopId={shopId}" : "api/bff/staff/schedules";
return await GetListFromApiAsync<ScheduleInfo>(url);
}
// ═══ ORDERS ═══
public record OrderInfo(Guid Id, Guid ShopId, decimal TotalAmount, int StatusId, DateTime CreatedAt,
string? Status, string? PaymentMethod, string? Notes, int ItemCount = 0);
public async Task<List<OrderInfo>> GetOrdersAsync(Guid? shopId = null, string filter = "today")
{
var url = shopId.HasValue
? $"api/bff/orders?shopId={shopId}&filter={filter}"
: $"api/bff/orders?filter={filter}";
return await GetListFromApiAsync<OrderInfo>(url);
}
// ═══ 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()
=> await GetListFromApiAsync<WalletInfo>("api/bff/wallets");
public async Task<List<WalletTxnInfo>> GetWalletTransactionsAsync(int limit = 50)
=> await GetListFromApiAsync<WalletTxnInfo>($"api/bff/wallet/transactions?limit={limit}");
// ═══ DEVICES ═══
public record DeviceInfo(Guid Id, string? DeviceToken, string? Platform, bool IsActive, DateTime CreatedAt, string? StaffCode);
public async Task<List<DeviceInfo>> GetDevicesAsync()
=> await GetListFromApiAsync<DeviceInfo>("api/bff/devices");
// ═══ 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()
=> await GetListFromApiAsync<PromotionInfo>("api/bff/promotions");
// ═══ CAMPAIGNS CRUD ═══
// EN: Campaign record and request DTOs for CRUD operations
// VI: Record chiến dịch và DTO yêu cầu cho CRUD
public record CampaignInfo(Guid Id, string Name, string? Description, decimal FaceValue,
int TotalVouchers, int IssuedVouchers, DateTime? StartDate, DateTime? EndDate, string? Status, DateTime CreatedAt);
public record PaginatedCampaignResponse(List<CampaignInfo>? Items, int TotalCount, int PageNumber, int PageSize, int TotalPages);
public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate);
public async Task<List<CampaignInfo>> GetCampaignsAsync()
{
AttachToken();
var resp = await _http.GetFromJsonAsync<PaginatedCampaignResponse>("api/bff/campaigns", _jsonOptions);
return resp?.Items ?? new();
}
public async Task<bool> CreateCampaignAsync(CreateCampaignRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/campaigns", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCampaignAsync(Guid campaignId, CreateCampaignRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/campaigns/{campaignId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteCampaignAsync(Guid campaignId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/campaigns/{campaignId}");
return resp.IsSuccessStatusCode;
}
// ═══ MEMBER CRUD ═══
// 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, string? Name = null, string? Phone = null);
public record UpdateMemberRequest(string? Gender, string? Preferences);
public async Task<(bool Ok, string? Error)> CreateMemberAsync(CreateMemberRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/members", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
return (false, err);
}
public async Task<bool> UpdateMemberAsync(Guid memberId, UpdateMemberRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/members/{memberId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteMemberAsync(Guid memberId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/members/{memberId}");
return resp.IsSuccessStatusCode;
}
// ═══ 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)
{
var url = shopId.HasValue ? $"api/bff/inventory/transactions?shopId={shopId}" : "api/bff/inventory/transactions";
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 LevelNumber, string Name, int RequiredExp,
string? Description = null, string? BadgeColor = null, bool IsActive = true, int MemberCount = 0,
int MinExp = 0, int MaxExp = 0)
{
public int Level => LevelNumber;
};
public async Task<List<LevelDefinitionInfo>> GetMembershipLevelsAsync()
=> await GetListFromApiAsync<LevelDefinitionInfo>("api/bff/membership/levels");
public static List<LevelDefinitionInfo> EnrichLevelDefinitions(List<LevelDefinitionInfo> levels, List<MemberInfo> members)
{
if (!levels.Any()) return levels;
var sorted = levels.OrderBy(l => l.LevelNumber).ToList();
var enriched = new List<LevelDefinitionInfo>();
for (int i = 0; i < sorted.Count; i++)
{
var lvl = sorted[i];
int minExp = lvl.RequiredExp;
int maxExp = (i + 1 < sorted.Count) ? sorted[i + 1].RequiredExp - 1 : int.MaxValue;
int memberCount = members.Count(m => m.CurrentLevel == lvl.LevelNumber);
enriched.Add(lvl with { MinExp = minExp, MaxExp = maxExp == int.MaxValue ? 999999 : maxExp, MemberCount = memberCount });
}
return enriched;
}
public static List<MemberInfo> ResolveMemberLevelNames(List<MemberInfo> members, List<LevelDefinitionInfo> levels)
{
var levelMap = levels.ToDictionary(l => l.LevelNumber, l => l.Name);
return members.Select(m => m with { LevelName = levelMap.GetValueOrDefault(m.CurrentLevel) ?? m.LevelName }).ToList();
}
// ═══ LEVEL DEFINITION CRUD ═══
public record CreateLevelRequest(int LevelNumber, string Name, int RequiredExp, string? Description, string? BadgeColor);
public async Task<(bool Ok, string? Error)> CreateLevelAsync(CreateLevelRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/membership/levels", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, await TryExtractError(resp));
}
public async Task<(bool Ok, string? Error)> UpdateLevelAsync(Guid levelId, CreateLevelRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/membership/levels/{levelId}", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, await TryExtractError(resp));
}
public async Task<bool> DeleteLevelAsync(Guid levelId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/membership/levels/{levelId}");
return resp.IsSuccessStatusCode;
}
// ═══ EXP MANAGEMENT ═══
public record AddExpRequest(int Points, int SourceId, string? ReferenceId);
public record AddExpResult(Guid MemberId, int PointsAdded, int CurrentExp, int TotalExpEarned,
int PreviousLevel, int CurrentLevel, bool LeveledUp);
public record MemberProgressInfo(Guid MemberId, int CurrentLevel, string? CurrentLevelName,
int CurrentExp, int TotalExpEarned, int ExpToNextLevel, int ProgressPercent,
int? NextLevel, string? NextLevelName, string? BadgeColor);
public record ExpTransactionInfo(Guid Id, int Points, string? Source, int SourceId,
string? ReferenceId, int LevelAtTime, DateTime CreatedAt);
public async Task<AddExpResult?> AddExperienceAsync(Guid memberId, AddExpRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/members/{memberId}/experience", req, _writeOptions);
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<AddExpResult>(_jsonOptions);
return null;
}
public async Task<MemberProgressInfo?> GetMemberProgressAsync(Guid memberId)
=> await GetObjectFromApiAsync<MemberProgressInfo>($"api/bff/members/{memberId}/progress");
public async Task<List<ExpTransactionInfo>> GetExperienceHistoryAsync(Guid memberId)
=> await GetListFromApiAsync<ExpTransactionInfo>($"api/bff/members/{memberId}/experience");
// ═══ FILE UPLOAD (Storage Service) ═══
public async Task<string?> UploadImageAsync(Stream fileStream, string fileName, string contentType)
{
AttachToken();
using var content = new MultipartFormDataContent();
var streamContent = new StreamContent(fileStream);
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(streamContent, "file", fileName);
var resp = await _http.PostAsync("api/bff/files/upload?accessLevel=public", content);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
// EN: Try to extract download URL or file ID from response
if (doc.RootElement.TryGetProperty("downloadUrl", out var url))
return url.GetString();
if (doc.RootElement.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("downloadUrl", out var dUrl)) return dUrl.GetString();
if (data.TryGetProperty("fileId", out var fId)) return $"api/bff/files/{fId.GetString()}/download";
}
if (doc.RootElement.TryGetProperty("fileId", out var fileId))
return $"api/bff/files/{fileId.GetString()}/download";
return null;
}
// ═══ SHOP STATS (aggregated per-shop) ═══
public record ShopStatsInfo(Guid ShopId, int ProductCount, int OrderCount, int StaffCount, decimal Revenue);
public async Task<List<ShopStatsInfo>> GetShopStatsAsync()
=> await GetListFromApiAsync<ShopStatsInfo>("api/bff/shops/stats");
// ═══ BOOKING RESOURCES ═══
public record ResourceInfo(Guid Id, string Name, string? ResourceType, int Capacity, bool IsActive);
public async Task<List<ResourceInfo>> GetResourcesAsync(Guid shopId)
=> await GetListFromApiAsync<ResourceInfo>($"api/bff/shops/{shopId}/resources");
// ═══ POS DASHBOARD (real-time daily stats) ═══
// EN: POS dashboard response DTOs — matching camelCase API response from OrderService
// VI: DTOs cho response dashboard POS — khớp camelCase API response từ OrderService
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(Guid ProductId, string ProductName, int QuantitySold, decimal Revenue)
{
// EN: Aliases for Razor display / VI: Alias cho hiển thị Razor
public string Name => ProductName;
public int Qty => QuantitySold;
}
public record PaymentBreakdownInfo(string Method, int Count, decimal Amount)
{
public int Pct { get; set; }
}
public record HourlyRevenueInfo(int Hour, decimal Revenue, int OrderCount)
{
public string HourLabel => $"{Hour}h";
public int Pct { get; set; }
}
public record RecentOrderInfo(Guid Id, decimal TotalAmount, string Status, int ItemCount, DateTime CreatedAt);
public async Task<PosDashboardInfo> GetPosDashboardAsync(Guid shopId, string? period = "today")
{
AttachToken();
return await _http.GetFromJsonAsync<PosDashboardInfo>(
$"api/bff/pos/dashboard?shopId={shopId}&period={period ?? "today"}", _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,
decimal? DiscountAmount = null, string? DiscountType = null, string? DiscountReference = null, Guid? TableId = 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);
public async Task<CreatePosOrderResponse?> CreatePosOrderAsync(CreatePosOrderRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/pos/orders", req, _writeOptions);
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<CreatePosOrderResponse>(_jsonOptions);
return null;
}
// ═══ PAY ORDER ═══
public async Task<bool> PayOrderAsync(Guid orderId, Guid shopId)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", new { }, _writeOptions);
return resp.IsSuccessStatusCode;
}
// ═══ ACTIVE TABLE ORDERS ═══
// EN: DTOs for active table orders (orders with table_id, status=Validated)
// VI: DTOs cho active table orders (orders có table_id, status=Validated)
public record ActiveTableOrderDto
{
public Guid OrderId { get; init; }
public Guid? TableId { get; init; }
public decimal TotalAmount { get; init; }
public DateTime CreatedAt { get; init; }
public List<ActiveTableOrderItemDto> Items { get; init; } = new();
}
public record ActiveTableOrderItemDto
{
public Guid ProductId { get; init; }
public string ProductName { get; init; } = string.Empty;
public int Quantity { get; init; }
public decimal UnitPrice { get; init; }
}
public async Task<List<ActiveTableOrderDto>> GetActiveTableOrdersAsync(Guid shopId)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/orders/active-by-table?shopId={shopId}");
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<List<ActiveTableOrderDto>>(_jsonOptions) ?? new();
return new();
}
// ═══ 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, string? ImageUrl = null);
public async Task<bool> CreateCategoryAsync(AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCategoryAsync(Guid categoryId, AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _writeOptions);
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, Guid? shopId = null)
{
AttachToken();
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
return await _http.GetFromJsonAsync<OrderDetailResponse>($"api/bff/orders/{orderId}{qs}", _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, _writeOptions);
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)
{
var url = shopId.HasValue
? $"api/bff/reports/revenue?period={period}&shopId={shopId}"
: $"api/bff/reports/revenue?period={period}";
return await GetListFromApiAsync<RevenueReportItem>(url);
}
// ═══ SHOP SETTINGS ═══
public record ShopFeaturesInfo
{
public bool HasInventory { get; init; }
public bool HasBooking { get; init; }
public bool HasTables { get; init; }
public bool HasKitchen { get; init; }
public bool HasShipping { get; init; }
public bool HasDelivery { get; init; }
}
public record ShopSettingsInfo
{
public Guid ShopId { get; init; }
public ShopFeaturesInfo? Features { get; init; }
public string? OpenTime { get; init; }
public string? CloseTime { get; init; }
public List<string>? OpenDays { get; init; }
}
public record UpdateShopSettingsRequest(ShopFeaturesInfo? Features, string? OpenTime, string? CloseTime, List<string>? OpenDays);
public async Task<ShopSettingsInfo?> GetShopSettingsAsync(Guid shopId)
=> await GetObjectFromApiAsync<ShopSettingsInfo>($"api/bff/shops/{shopId}/settings");
public async Task<bool> UpdateShopSettingsAsync(Guid shopId, UpdateShopSettingsRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}/settings", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
// ═══ TOP PRODUCTS ═══
public record TopProductInfo(string? ProductName, long TotalSold, decimal TotalRevenue);
public async Task<List<TopProductInfo>> GetTopProductsAsync(Guid? shopId = null, int limit = 10)
{
var url = shopId.HasValue
? $"api/bff/reports/top-products?shopId={shopId}&limit={limit}"
: $"api/bff/reports/top-products?limit={limit}";
return await GetListFromApiAsync<TopProductInfo>(url);
}
// ═══ TABLES CRUD ═══
public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone, decimal? HourlyRate = null);
public async Task<bool> CreateTableAsync(CreateTableRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/tables", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateTableAsync(Guid tableId, CreateTableRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateTablePositionAsync(Guid tableId, int x, int y)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", new { positionX = x, positionY = y }, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> DeleteTableAsync(Guid tableId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/tables/{tableId}"); return r.IsSuccessStatusCode; }
// ═══ APPOINTMENTS CRUD ═══
public record CreateAppointmentRequest(Guid ShopId, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid? ServiceId, DateTime StartTime, DateTime EndTime, string? Status = null);
public async Task<bool> CreateAppointmentAsync(CreateAppointmentRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/appointments", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateAppointmentAsync(Guid apptId, CreateAppointmentRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/appointments/{apptId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> CancelAppointmentAsync(Guid apptId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/appointments/{apptId}/cancel"); return r.IsSuccessStatusCode; }
// ═══ RESOURCES CRUD ═══
public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity);
public async Task<bool> CreateResourceAsync(CreateResourceRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/resources", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateResourceAsync(Guid resourceId, CreateResourceRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/resources/{resourceId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> DeleteResourceAsync(Guid resourceId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/resources/{resourceId}"); return r.IsSuccessStatusCode; }
// ═══ STAFF SCHEDULES CRUD ═══
public record CreateScheduleRequest(Guid ShopId, Guid StaffId, int DayOfWeek, string StartTime, string EndTime);
public async Task<bool> CreateScheduleAsync(CreateScheduleRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/staff/schedules", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateScheduleAsync(Guid scheduleId, CreateScheduleRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/staff/schedules/{scheduleId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> DeleteScheduleAsync(Guid scheduleId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/staff/schedules/{scheduleId}"); return r.IsSuccessStatusCode; }
// ═══ KITCHEN TICKETS ═══
public record KitchenTicketInfo(Guid Id, Guid SessionId, Guid OrderItemId, string ItemName, string? Station, int Priority, string Status, DateTime CreatedAt, DateTime? CompletedAt);
public record UpdateTicketStatusRequest(string Status);
public async Task<List<KitchenTicketInfo>> GetKitchenTicketsAsync(Guid? shopId = null, string status = "Pending")
{
if (!shopId.HasValue) return new();
var url = $"api/bff/shops/{shopId}/kitchen-tickets?status={status}";
return await GetListFromApiAsync<KitchenTicketInfo>(url);
}
public async Task<bool> UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req)
{
AttachToken();
var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/kitchen/tickets/{ticketId}/status")
{
Content = JsonContent.Create(req, options: _writeOptions)
};
var r = await _http.SendAsync(request);
return r.IsSuccessStatusCode;
}
// ═══ RECIPES CRUD ═══
public record RecipeIngredientInfo(Guid Id, Guid RecipeId, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
public record RecipeInfo(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions, int PrepTimeMinutes, bool IsActive, DateTime CreatedAt);
public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List<RecipeIngredientRequest>? Ingredients);
public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
public async Task<List<RecipeInfo>> GetRecipesAsync(Guid? shopId = null)
{
if (!shopId.HasValue) return new();
var url = $"api/bff/shops/{shopId}/recipes";
return await GetListFromApiAsync<RecipeInfo>(url);
}
public async Task<bool> CreateRecipeAsync(CreateRecipeRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/recipes", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateRecipeAsync(Guid recipeId, CreateRecipeRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/recipes/{recipeId}", req, _writeOptions); return r.IsSuccessStatusCode; }
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;
}
// ═══ ADMIN VOUCHER MANAGEMENT ═══
public record AdminVoucherInfo(Guid Id, Guid CampaignId, string? CampaignName, string? Code,
Guid? OwnerId, string? OwnerEmail, decimal FaceValue, decimal RemainingValue, string? Status,
DateTime? ClaimedAt, DateTime? ExpiresAt, DateTime? RedeemedAt, DateTime? CreatedAt);
public record PaginatedVoucherResponse(List<AdminVoucherInfo>? Items, int TotalCount, int PageNumber, int PageSize, int TotalPages);
public async Task<List<AdminVoucherInfo>> GetAdminVouchersAsync(Guid? campaignId = null, string? status = null, int pageSize = 50)
{
AttachToken();
var qs = $"pageSize={pageSize}";
if (campaignId.HasValue) qs += $"&campaignId={campaignId}";
if (!string.IsNullOrEmpty(status)) qs += $"&status={status}";
var resp = await _http.GetFromJsonAsync<PaginatedVoucherResponse>($"api/bff/vouchers/list?{qs}", _jsonOptions);
return resp?.Items ?? new();
}
public async Task<bool> RevokeVoucherAsync(Guid voucherId)
{ AttachToken(); var r = await _http.PostAsync($"api/bff/vouchers/{voucherId}/revoke", null); return r.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 Ok, string? Error)> InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff/invite-with-account", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
return (false, err);
}
// ═══ STORAGE / DRIVE ═══
public record StorageFileInfo(Guid Id, string FileName, string? ContentType, long FileSizeBytes, string? AccessLevel, DateTime UploadedAt);
public record StorageFolderInfo(Guid Id, Guid? ParentId, string Name, string? Path, DateTime CreatedAt);
public record CreateFolderRequest(string Name, Guid? ParentId);
// Storage API wraps responses in { success, data, error }
private record StorageApiResponse<T>(bool Success, T? Data, string? Error);
private record UserFilesResult(List<StorageFileInfo> Files, int TotalCount);
public async Task<List<StorageFileInfo>> GetStorageFilesAsync(int skip = 0, int take = 50, string? search = null)
{
AttachToken();
var qs = $"?skip={skip}&take={take}";
if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}";
var wrapper = await _http.GetFromJsonAsync<StorageApiResponse<UserFilesResult>>($"api/bff/files{qs}", _jsonOptions);
return wrapper?.Data?.Files ?? new();
}
public async Task<bool> DeleteStorageFileAsync(Guid fileId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/files/{fileId}"); return r.IsSuccessStatusCode; }
public async Task<string?> GetDownloadUrlAsync(Guid fileId)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/files/{fileId}/download-url");
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadFromJsonAsync<JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("url", out var url)) return url.GetString();
if (data.TryGetProperty("downloadUrl", out var dl)) return dl.GetString();
}
if (json.TryGetProperty("url", out var directUrl)) return directUrl.GetString();
return null;
}
public async Task<List<StorageFolderInfo>> GetFoldersAsync(Guid? parentId = null)
{
AttachToken();
var qs = parentId.HasValue ? $"?parentId={parentId}" : "";
var wrapper = await _http.GetFromJsonAsync<StorageApiResponse<IEnumerable<StorageFolderInfo>>>($"api/bff/folders{qs}", _jsonOptions);
return wrapper?.Data?.ToList() ?? new();
}
public async Task<(bool Ok, string? Error)> CreateFolderAsync(CreateFolderRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/folders", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
return (false, err);
}
public async Task<bool> DeleteFolderAsync(Guid folderId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/folders/{folderId}"); return r.IsSuccessStatusCode; }
public async Task<bool> UploadFileRawAsync(MultipartFormDataContent content)
{
AttachToken();
var resp = await _http.PostAsync("api/bff/files/upload?accessLevel=public", content);
return resp.IsSuccessStatusCode;
}
// ═══ TABLE QR ═══
public async Task<string?> GenerateTableQrTokenAsync(Guid tableId)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/generate-qr", new { }, _writeOptions);
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data) && data.TryGetProperty("qrToken", out var token))
return token.GetString();
}
return null;
}
public record TableByTokenInfo(Guid Id, Guid ShopId, string TableNumber, int Capacity, string? Zone);
public async Task<TableByTokenInfo?> GetTableByTokenAsync(string token)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/tables/by-token/{token}");
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<TableByTokenInfo>(data.GetRawText(), _jsonOptions);
}
return null;
}
// ═══ RESERVATIONS ═══
public record ReservationInfo(Guid Id, Guid ShopId, Guid? TableId, string GuestName, string? Phone,
int PartySize, DateTime ReservationTime, string Status, string? Note, DateTime CreatedAt);
public record CreateReservationRequest2(Guid ShopId, string GuestName, int PartySize, DateTime ReservationTime,
string? Phone = null, Guid? TableId = null, string? Note = null);
public async Task<List<ReservationInfo>> GetReservationsAsync(Guid shopId, string? date = null)
{
var url = $"api/bff/shops/{shopId}/reservations";
if (!string.IsNullOrEmpty(date)) url += $"?date={Uri.EscapeDataString(date)}";
return await GetListFromApiAsync<ReservationInfo>(url);
}
public async Task<bool> CreateReservationAsync(CreateReservationRequest2 req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/reservations", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateReservationStatusAsync(Guid reservationId, string status)
{
AttachToken();
var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/reservations/{reservationId}/status")
{
Content = JsonContent.Create(new { status }, options: _writeOptions)
};
var r = await _http.SendAsync(request);
return r.IsSuccessStatusCode;
}
// ═══ TABLE STATUS ═══
public async Task<bool> UpdateTableStatusAsync(Guid tableId, string status)
{
AttachToken();
var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/tables/{tableId}/status")
{
Content = JsonContent.Create(new { status }, options: _writeOptions)
};
var r = await _http.SendAsync(request);
return r.IsSuccessStatusCode;
}
// ═══ SESSIONS ═══
public record SessionInfo(Guid Id, Guid TableId, Guid ShopId, int GuestCount, DateTime StartedAt, DateTime? ClosedAt, string Status);
public async Task<SessionInfo?> OpenSessionAsync(Guid tableId, Guid shopId, int guestCount = 1)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/sessions",
new { tableId, shopId, guestCount }, _writeOptions);
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data))
{
var sessionId = data.GetProperty("sessionId").GetGuid();
return new SessionInfo(sessionId, tableId, shopId, guestCount, DateTime.UtcNow, null, "Open");
}
}
return null;
}
public async Task<SessionInfo?> GetSessionAsync(Guid sessionId)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/sessions/{sessionId}");
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<SessionInfo>(data.GetRawText(), _jsonOptions);
}
return null;
}
public async Task<bool> CloseSessionAsync(Guid sessionId)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/sessions/{sessionId}/close", new { }, _writeOptions);
return resp.IsSuccessStatusCode;
}
// ═══ SHOP PUBLISH (draft → active) ═══
public async Task<bool> PublishShopAsync(Guid shopId)
{
AttachToken();
var resp = await _http.PostAsync($"api/bff/shops/{shopId}/publish", null);
return resp.IsSuccessStatusCode;
}
// ═══ SERVICE HEALTH CHECK ═══
public record ServiceHealthInfo(string Name, string Icon, bool IsOnline, int? LatencyMs);
private static readonly (string Name, string Icon, string HealthPath)[] _healthEndpoints = new[]
{
("IAM Service", "shield", "api/iam/health"),
("Merchant Service", "store", "api/merchants/health"),
("Catalog Service", "package", "api/catalog/health"),
("Order Service", "shopping-bag", "api/orders/health"),
};
public async Task<List<ServiceHealthInfo>> CheckServicesHealthAsync()
{
var results = new List<ServiceHealthInfo>();
var tasks = _healthEndpoints.Select(async svc =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var resp = await _http.GetAsync(svc.HealthPath, cts.Token);
sw.Stop();
return new ServiceHealthInfo(svc.Name, svc.Icon, resp.IsSuccessStatusCode, (int)sw.ElapsedMilliseconds);
}
catch
{
sw.Stop();
return new ServiceHealthInfo(svc.Name, svc.Icon, false, null);
}
}).ToArray();
return (await Task.WhenAll(tasks)).ToList();
}
}