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.