diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs index 047e709f..14b6d8f3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using WebClientTpos.Server.Infrastructure; @@ -6,17 +7,29 @@ namespace WebClientTpos.Server.Controllers; /// /// EN: Shop management controller — proxies to MerchantService for shop CRUD, settings, stats, devices. +/// Stats endpoint aggregates data from Merchant, Catalog, and Order services. /// VI: Controller quản lý cửa hàng — proxy đến MerchantService cho CRUD shop, settings, stats, devices. +/// Endpoint stats tổng hợp dữ liệu từ Merchant, Catalog, và Order services. /// [ApiController] [Route("api/bff")] public class ShopController : ControllerBase { private readonly HttpClient _merchant; + private readonly HttpClient _catalog; + private readonly HttpClient _order; + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; public ShopController(IHttpClientFactory httpClientFactory) { _merchant = httpClientFactory.CreateClient("MerchantService"); + _catalog = httpClientFactory.CreateClient("CatalogService"); + _order = httpClientFactory.CreateClient("OrderService"); } /// @@ -60,12 +73,135 @@ public class ShopController : ControllerBase _merchant.PutAsJsonAsync($"/api/v1/shops/{shopId}/settings", body).ProxyAsync(); /// - /// EN: Get aggregated stats per shop. - /// VI: Lấy thống kê tổng hợp theo shop. + /// EN: Get aggregated stats per shop — calls Merchant (shops+staff), Catalog (product count), Order (revenue+orders) in parallel. + /// VI: Lấy thống kê tổng hợp theo shop — gọi Merchant (shops+staff), Catalog (số SP), Order (doanh thu+đơn) song song. /// [HttpGet("shops/stats")] - public Task GetShopStats() => - _merchant.GetAsync("/api/v1/shops/stats").ProxyAsync(); + public async Task GetShopStats() + { + // Step 1: Get shops list from Merchant service + var shopsResponse = await _merchant.GetAsync("/api/v1/shops"); + if (!shopsResponse.IsSuccessStatusCode) + return StatusCode((int)shopsResponse.StatusCode, "Failed to load shops"); + + var shopsJson = await shopsResponse.Content.ReadAsStringAsync(); + var shops = JsonSerializer.Deserialize>(shopsJson, JsonOpts) ?? []; + if (shops.Count == 0) + return Ok("[]"); + + // Step 2: Fetch staff list once (shared across all shops) + per-shop stats in parallel + var staffMapTask = GetStaffCountMapAsync(); + var perShopTasks = shops.Select(async shop => + { + var productTask = GetProductCountAsync(shop.Id); + var orderTask = GetOrderStatsAsync(shop.Id); + await Task.WhenAll(productTask, orderTask); + return (shop.Id, ProductCount: await productTask, OrderStats: await orderTask); + }); + + await Task.WhenAll(staffMapTask, Task.WhenAll(perShopTasks)); + var staffMap = await staffMapTask; + var perShopResults = await Task.WhenAll(perShopTasks); + + var results = perShopResults.Select(r => new + { + shopId = r.Id, + productCount = r.ProductCount, + orderCount = r.OrderStats.OrderCount, + staffCount = staffMap.GetValueOrDefault(r.Id, 0), + revenue = r.OrderStats.Revenue + }); + + return new ContentResult + { + StatusCode = 200, + Content = JsonSerializer.Serialize(results, JsonOpts), + ContentType = "application/json" + }; + } + + private async Task GetProductCountAsync(Guid shopId) + { + try + { + var resp = await _catalog.GetAsync($"/api/v1.0/products?shopId={shopId}&pageSize=1"); + if (!resp.IsSuccessStatusCode) return 0; + var json = await resp.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + // Try common paged response shapes + if (doc.RootElement.TryGetProperty("totalCount", out var tc)) return tc.GetInt32(); + if (doc.RootElement.TryGetProperty("TotalCount", out var tc2)) return tc2.GetInt32(); + if (doc.RootElement.TryGetProperty("total", out var t)) return t.GetInt32(); + if (doc.RootElement.ValueKind == JsonValueKind.Array) return doc.RootElement.GetArrayLength(); + return 0; + } + catch { return 0; } + } + + private async Task<(int OrderCount, decimal Revenue)> GetOrderStatsAsync(Guid shopId) + { + try + { + // Primary: Use POS dashboard endpoint which has Revenue + OrderCount + var resp = await _order.GetAsync($"/api/v1.0/orders/dashboard?shopId={shopId}&period=30d"); + if (resp.IsSuccessStatusCode) + { + var json = await resp.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + var orders = 0; + var revenue = 0m; + if (doc.RootElement.TryGetProperty("orderCount", out var oc)) orders = (int)oc.GetInt64(); + if (doc.RootElement.TryGetProperty("revenue", out var rv)) revenue = rv.GetDecimal(); + return (orders, revenue); + } + // Fallback: Use orders list to get totalCount + var ordersResp = await _order.GetAsync($"/api/v1.0/orders?shopId={shopId}&pageSize=1"); + if (!ordersResp.IsSuccessStatusCode) return (0, 0m); + var ordersJson = await ordersResp.Content.ReadAsStringAsync(); + var ordersDoc = JsonDocument.Parse(ordersJson); + if (ordersDoc.RootElement.TryGetProperty("totalCount", out var otc)) + return (otc.GetInt32(), 0m); + return (0, 0m); + } + catch { return (0, 0m); } + } + + /// + /// EN: Fetch all merchant staff once and return a per-shop count map. + /// VI: Lấy tất cả staff merchant 1 lần và trả về map đếm theo shop. + /// + private async Task> GetStaffCountMapAsync() + { + var map = new Dictionary(); + try + { + var resp = await _merchant.GetAsync("/api/v1/merchants/me/staff"); + if (!resp.IsSuccessStatusCode) return map; + var json = await resp.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return map; + foreach (var staff in doc.RootElement.EnumerateArray()) + { + if (!staff.TryGetProperty("shopAssignments", out var assignments)) continue; + foreach (var assignment in assignments.EnumerateArray()) + { + if (assignment.TryGetProperty("shopId", out var sid) && + Guid.TryParse(sid.GetString(), out var assignedShopId)) + { + map[assignedShopId] = map.GetValueOrDefault(assignedShopId, 0) + 1; + } + } + } + } + catch { /* silent fallback */ } + return map; + } + + private record ShopBasicDto + { + public Guid Id { get; init; } + public string? Name { get; init; } + } /// /// EN: Publish a shop (draft → active). diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs index dae556dc..ba3c19cd 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs @@ -80,16 +80,46 @@ public class StaffController : ControllerBase new { success = false, message = friendlyMessage }); } - // Step 2: Invite staff via MerchantService - var invitePayload = new + // Step 2: Extract userId from IAM response + Guid? iamUserId = null; + try { - 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, - firstName = iamPayload.firstName, - lastName = iamPayload.lastName - }; - return await _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/invite", invitePayload).ProxyAsync(); + var iamJson = await iamResponse.Content.ReadAsStringAsync(); + using var iamDoc = JsonDocument.Parse(iamJson); + // IAM returns ApiResponse with data.userId + if (iamDoc.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("userId", out var uid)) + iamUserId = Guid.TryParse(uid.GetString(), out var parsed) ? parsed : null; + } + catch { /* fallback to invite flow */ } + + if (iamUserId.HasValue) + { + // Step 3a: Create staff directly as Active (owner created the account) + var createPayload = new + { + userId = iamUserId.Value, + email = iamPayload.email, + role = body.TryGetProperty("role", out var r1) ? r1.GetString() : "Cashier", + shopId = body.TryGetProperty("shopId", out var s1) ? s1.GetString() : null as string, + firstName = iamPayload.firstName, + lastName = iamPayload.lastName + }; + return await _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/create-active", createPayload).ProxyAsync(); + } + else + { + // Step 3b: Fallback to invite flow if userId not extracted + var invitePayload = new + { + email = iamPayload.email, + role = body.TryGetProperty("role", out var r2) ? r2.GetString() : "Cashier", + shopId = body.TryGetProperty("shopId", out var s2) ? s2.GetString() : null as string, + firstName = iamPayload.firstName, + lastName = iamPayload.lastName + }; + return await _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/invite", invitePayload).ProxyAsync(); + } } /// diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Staff/StaffCommands.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Staff/StaffCommands.cs index bdad8120..afe3df0b 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Staff/StaffCommands.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Staff/StaffCommands.cs @@ -96,6 +96,102 @@ public class InviteStaffCommandHandler : IRequestHandler +/// EN: Command to create a staff member directly as Active (when owner creates IAM account for them). +/// VI: Command để tạo nhân viên trực tiếp Active (khi chủ DN tạo tài khoản IAM cho họ). +/// +public record CreateActiveStaffCommand : IRequest +{ + public Guid UserId { get; init; } + public string Email { get; init; } = null!; + public string Role { get; init; } = "Cashier"; + public Guid? ShopId { get; init; } + public string? FirstName { get; init; } + public string? LastName { get; init; } +} + +/// +/// EN: Handler for CreateActiveStaffCommand. +/// VI: Handler cho CreateActiveStaffCommand. +/// +public class CreateActiveStaffCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IMerchantStaffRepository _staffRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public CreateActiveStaffCommandHandler( + IMerchantRepository merchantRepository, + IMerchantStaffRepository staffRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _staffRepository = staffRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(CreateActiveStaffCommand request, CancellationToken cancellationToken) + { + var ownerUserId = GetUserId(); + var merchant = await _merchantRepository.GetByUserIdAsync(ownerUserId, cancellationToken) + ?? throw new DomainException("Merchant not found"); + + var role = request.Role.ToLowerInvariant() switch + { + "cashier" => StaffRole.Cashier, + "waiter" => StaffRole.Waiter, + "manager" => StaffRole.Manager, + "admin" => StaffRole.Admin, + _ => StaffRole.Cashier + }; + + var staff = MerchantStaff.CreateActive( + merchant.Id, + request.UserId, + request.Email, + role, + StaffPermissions.ViewSales | StaffPermissions.ProcessPayment, + request.FirstName, + request.LastName); + + if (request.ShopId.HasValue) + { + var shopRole = request.Role.ToLowerInvariant() switch + { + "cashier" => ShopRole.Cashier, + "waiter" => ShopRole.Waiter, + "manager" => ShopRole.Manager, + _ => ShopRole.Cashier + }; + staff.AssignToShop(request.ShopId.Value, shopRole); + } + + _staffRepository.Add(staff); + await _staffRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Staff created active: {StaffId} for merchant {MerchantId} with IAM userId {UserId}", + staff.Id, merchant.Id, request.UserId); + + return new InviteStaffResult(staff.Id, staff.Email ?? request.Email, staff.Status.Name); + } + + private Guid GetUserId() + { + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + throw new DomainException("User not authenticated"); + return userId; + } +} + +#endregion + #region Update Staff Command /// diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs index 47fceaca..7b335c1e 100644 --- a/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs @@ -39,6 +39,36 @@ public class StaffController : ControllerBase return Ok(result); } + /// + /// EN: Create a staff member directly as Active (owner already created IAM account). + /// VI: Tạo nhân viên trực tiếp Active (chủ DN đã tạo tài khoản IAM). + /// + [HttpPost("create-active")] + [ProducesResponseType(typeof(InviteStaffResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateActiveStaff([FromBody] CreateActiveStaffRequest request) + { + try + { + var command = new CreateActiveStaffCommand + { + UserId = request.UserId, + Email = request.Email, + Role = request.Role, + ShopId = request.ShopId, + FirstName = request.FirstName, + LastName = request.LastName + }; + var result = await _mediator.Send(command); + _logger.LogInformation("Staff created active: {Email} with userId {UserId}", request.Email, request.UserId); + return CreatedAtAction(nameof(GetMyStaff), result); + } + catch (Domain.Exceptions.DomainException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + /// /// EN: Invite a new staff member. /// VI: Mời nhân viên mới. @@ -191,6 +221,16 @@ public class StaffPublicController : ControllerBase #region Request Models +public record CreateActiveStaffRequest +{ + public Guid UserId { get; init; } + public string Email { get; init; } = null!; + public string Role { get; init; } = "Cashier"; + public Guid? ShopId { get; init; } + public string? FirstName { get; init; } + public string? LastName { get; init; } +} + public record InviteStaffRequest { public string Email { get; init; } = null!; diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs index 2d542557..71f70f50 100644 --- a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs @@ -196,6 +196,48 @@ public class MerchantStaff : Entity, IAggregateRoot return staff; } + /// + /// EN: Create an active staff member directly (when owner creates account for staff, no invite needed). + /// VI: Tạo nhân viên active trực tiếp (khi chủ DN tạo tài khoản cho NV, không cần mời). + /// + public static MerchantStaff CreateActive( + Guid merchantId, + Guid userId, + string email, + StaffRole role, + StaffPermissions permissions = StaffPermissions.None, + string? firstName = null, + string? lastName = null) + { + if (merchantId == Guid.Empty) + throw new DomainException("Merchant ID cannot be empty"); + if (userId == Guid.Empty) + throw new DomainException("User ID cannot be empty"); + if (string.IsNullOrWhiteSpace(email)) + throw new DomainException("Email cannot be empty"); + ArgumentNullException.ThrowIfNull(role, nameof(role)); + + var staff = new MerchantStaff + { + Id = Guid.NewGuid(), + _userId = userId, + _merchantId = merchantId, + _email = email.Trim().ToLowerInvariant(), + _role = role, + RoleId = role.Id, + _status = StaffStatus.Active, + StatusId = StaffStatus.Active.Id, + _permissions = permissions, + _firstName = firstName?.Trim(), + _lastName = lastName?.Trim(), + _createdAt = DateTime.UtcNow, + _joinedAt = DateTime.UtcNow + }; + + staff.AddDomainEvent(new StaffJoinedDomainEvent(staff)); + return staff; + } + /// /// EN: Accept invitation and link to IAM user. /// VI: Chấp nhận lời mời và liên kết với IAM user.