fix(staff): Vấn đề trạng thái nhân viên "Invited"
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user