fix(staff): Vấn đề trạng thái nhân viên "Invited"

This commit is contained in:
Ho Ngoc Hai
2026-03-05 15:56:37 +07:00
parent e4bedf2cd3
commit 81c5be9e37
5 changed files with 357 additions and 13 deletions

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
@@ -60,12 +73,135 @@ public class ShopController : ControllerBase
_merchant.PutAsJsonAsync($"/api/v1/shops/{shopId}/settings", body).ProxyAsync();
/// <summary>
/// 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.
/// </summary>
[HttpGet("shops/stats")]
public Task<IActionResult> GetShopStats() =>
_merchant.GetAsync("/api/v1/shops/stats").ProxyAsync();
public async Task<IActionResult> 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<List<ShopBasicDto>>(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<int> 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); }
}
/// <summary>
/// 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.
/// </summary>
private async Task<Dictionary<Guid, int>> GetStaffCountMapAsync()
{
var map = new Dictionary<Guid, int>();
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; }
}
/// <summary>
/// EN: Publish a shop (draft → active).

View File

@@ -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<RegisterUserCommandResult> 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();
}
}
/// <summary>

View File

@@ -96,6 +96,102 @@ public class InviteStaffCommandHandler : IRequestHandler<InviteStaffCommand, Inv
#endregion
#region Create Active Staff Command
/// <summary>
/// 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ọ).
/// </summary>
public record CreateActiveStaffCommand : IRequest<InviteStaffResult>
{
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; }
}
/// <summary>
/// EN: Handler for CreateActiveStaffCommand.
/// VI: Handler cho CreateActiveStaffCommand.
/// </summary>
public class CreateActiveStaffCommandHandler : IRequestHandler<CreateActiveStaffCommand, InviteStaffResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<CreateActiveStaffCommandHandler> _logger;
public CreateActiveStaffCommandHandler(
IMerchantRepository merchantRepository,
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<CreateActiveStaffCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<InviteStaffResult> 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
/// <summary>

View File

@@ -39,6 +39,36 @@ public class StaffController : ControllerBase
return Ok(result);
}
/// <summary>
/// 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).
/// </summary>
[HttpPost("create-active")]
[ProducesResponseType(typeof(InviteStaffResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// 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!;

View File

@@ -196,6 +196,48 @@ public class MerchantStaff : Entity, IAggregateRoot
return staff;
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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.