390 lines
16 KiB
C#
390 lines
16 KiB
C#
using Distribution.Infra.Authorization;
|
|
using Distribution.Infra.UoW;
|
|
|
|
namespace Distribution.Features.Region;
|
|
|
|
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
|
{
|
|
private const int RegionalAgencyRoleLevel = 60;
|
|
private const string CodePrefix = "REG-";
|
|
|
|
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
|
|
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
|
|
private Task<bool> CanManageRegionAsync(Guid regionId) => Task.FromResult(true);
|
|
|
|
public async Task<List<RegionResponse>> GetAllAsync() =>
|
|
(await repo.GetAllAsync())
|
|
.Where(r => r.IsActive)
|
|
.Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt))
|
|
.ToList();
|
|
|
|
public async Task<RegionResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var region = await repo.GetByIdAsync(id);
|
|
if (region is null || !region.IsActive) return null;
|
|
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
|
}
|
|
|
|
// Next code = highest existing REG-#### number + 1 (soft-deleted regions included so codes are never reused).
|
|
public async Task<NextRegionCodeResponse> GetNextCodeAsync() => new(await GenerateCodeAsync());
|
|
|
|
private async Task<string> GenerateCodeAsync()
|
|
{
|
|
var max = (await repo.GetAllCodesAsync())
|
|
.Where(c => c.StartsWith(CodePrefix, StringComparison.OrdinalIgnoreCase)
|
|
&& int.TryParse(c[CodePrefix.Length..], out _))
|
|
.Select(c => int.Parse(c[CodePrefix.Length..]))
|
|
.DefaultIfEmpty(0)
|
|
.Max();
|
|
return $"{CodePrefix}{max + 1:D4}";
|
|
}
|
|
|
|
public async Task<RegionResponse> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
|
|
{
|
|
string code;
|
|
if (string.IsNullOrWhiteSpace(request.Code))
|
|
code = await GenerateCodeAsync();
|
|
else
|
|
{
|
|
code = request.Code.Trim();
|
|
if (await repo.CodeExistsAsync(code))
|
|
throw new InvalidOperationException($"Region code '{code}' is already in use.");
|
|
}
|
|
var region = new Models.Region { Name = request.Name, Code = code };
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(region);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
|
}
|
|
|
|
public async Task<RegionResponse?> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
|
|
{
|
|
var region = await repo.GetByIdAsync(id);
|
|
if (region is null || !region.IsActive) return null;
|
|
region.Name = request.Name;
|
|
if (!string.IsNullOrWhiteSpace(request.Code))
|
|
{
|
|
var code = request.Code.Trim();
|
|
if (code != region.Code && await repo.CodeExistsAsync(code, id))
|
|
throw new InvalidOperationException($"Region code '{code}' is already in use.");
|
|
region.Code = code;
|
|
}
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(region);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
|
}
|
|
|
|
private const string RegionalManagerRole = "RegionalManager";
|
|
|
|
public async Task<RegionManagerResponse?> GetManagerAsync(Guid regionId)
|
|
{
|
|
var region = await repo.GetByIdAsync(regionId);
|
|
if (region is null || !region.IsActive) return null;
|
|
var scope = (await repo.GetManagerScopesAsync(regionId)).FirstOrDefault();
|
|
return scope is null ? null : new RegionManagerResponse(regionId, scope.FK_UserId, scope.User.Username, scope.User.FullName);
|
|
}
|
|
|
|
// A region has exactly one manager: assigning replaces any existing RegionalManager scoped to it.
|
|
// Returns null when the region does not exist; throws InvalidOperationException for an invalid user.
|
|
public async Task<RegionManagerResponse?> SetManagerAsync(Guid regionId, SetRegionManagerRequest request, CancellationToken ct = default)
|
|
{
|
|
var region = await repo.GetByIdAsync(regionId);
|
|
if (region is null || !region.IsActive) return null;
|
|
|
|
var user = await repo.GetUserWithRoleAsync(request.UserId);
|
|
if (user is null || !user.IsActive)
|
|
throw new InvalidOperationException("User not found.");
|
|
if (user.Role?.Name != RegionalManagerRole)
|
|
throw new InvalidOperationException($"User must have the {RegionalManagerRole} role to manage a region.");
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
var existing = await repo.GetManagerScopesAsync(regionId);
|
|
repo.RemoveRegionScopes(existing.Where(s => s.FK_UserId != user.Id));
|
|
if (await repo.GetRegionScopeAsync(user.Id, regionId) is null)
|
|
await repo.AddRegionScopeAsync(new Models.UserRegionScope { FK_UserId = user.Id, FK_RegionId = regionId });
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return new RegionManagerResponse(regionId, user.Id, user.Username, user.FullName);
|
|
}
|
|
|
|
public async Task<bool> RemoveManagerAsync(Guid regionId, CancellationToken ct = default)
|
|
{
|
|
var region = await repo.GetByIdAsync(regionId);
|
|
if (region is null || !region.IsActive) return false;
|
|
var existing = await repo.GetManagerScopesAsync(regionId);
|
|
if (existing.Count == 0) return false;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.RemoveRegionScopes(existing);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
var region = await repo.GetByIdAsync(id);
|
|
if (region is null || !region.IsActive) return false;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Remove(region);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Consolidated replacement for the old separate SetTarget/SetBudget/MapAgency/SetTaxRule
|
|
// endpoints. Each sub-action only runs if its fields are present on the request, so callers
|
|
// can update just one thing (as the UI does) or several at once in a single round trip.
|
|
public async Task<ManageRegionResponse?> ManageAsync(Guid regionId, ManageRegionRequest request, CancellationToken ct = default)
|
|
{
|
|
var region = await repo.GetByIdAsync(regionId);
|
|
if (region is null || !region.IsActive) return null;
|
|
if (!await CanManageRegionAsync(regionId)) return null;
|
|
|
|
RegionalTargetResponse? targetResponse = null;
|
|
RegionalBudgetResponse? budgetResponse = null;
|
|
RegionAgencyMapResponse? agencyMapResponse = null;
|
|
RegionalTaxRuleResponse? taxRuleResponse = null;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
if (request.TargetValue is not null && !string.IsNullOrWhiteSpace(request.Period))
|
|
{
|
|
var target = await repo.GetTargetAsync(regionId, request.Period);
|
|
if (target is null)
|
|
{
|
|
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = request.Period, TargetValue = request.TargetValue.Value };
|
|
await repo.AddTargetAsync(target);
|
|
}
|
|
else
|
|
{
|
|
target.TargetValue = request.TargetValue.Value;
|
|
target.IsActive = true;
|
|
repo.UpdateTarget(target);
|
|
}
|
|
targetResponse = new RegionalTargetResponse(regionId, request.Period, target.TargetValue);
|
|
}
|
|
|
|
if (request.BudgetAmount is not null && !string.IsNullOrWhiteSpace(request.Period))
|
|
{
|
|
var budget = await repo.GetBudgetAsync(regionId, request.Period);
|
|
if (budget is null)
|
|
{
|
|
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = request.Period, BudgetAmount = request.BudgetAmount.Value };
|
|
await repo.AddBudgetAsync(budget);
|
|
}
|
|
else
|
|
{
|
|
budget.BudgetAmount = request.BudgetAmount.Value;
|
|
budget.IsActive = true;
|
|
repo.UpdateBudget(budget);
|
|
}
|
|
budgetResponse = new RegionalBudgetResponse(regionId, request.Period, budget.BudgetAmount);
|
|
}
|
|
|
|
if (request.AgencyUserId is not null)
|
|
{
|
|
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId.Value);
|
|
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel)
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
return null;
|
|
}
|
|
|
|
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId.Value);
|
|
if (existing is null)
|
|
{
|
|
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId.Value };
|
|
await repo.AddAgencyMapAsync(map);
|
|
}
|
|
else if (!existing.IsActive)
|
|
{
|
|
existing.IsActive = true;
|
|
repo.UpdateAgencyMap(existing);
|
|
}
|
|
agencyMapResponse = new RegionAgencyMapResponse(regionId, request.AgencyUserId.Value);
|
|
}
|
|
|
|
if (request.RuleConfig is not null)
|
|
{
|
|
var rule = await repo.GetTaxRuleAsync(regionId);
|
|
if (rule is null)
|
|
{
|
|
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
|
|
await repo.AddTaxRuleAsync(rule);
|
|
}
|
|
else
|
|
{
|
|
rule.RuleConfig = request.RuleConfig;
|
|
rule.IsActive = true;
|
|
repo.UpdateTaxRule(rule);
|
|
}
|
|
taxRuleResponse = new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
|
|
}
|
|
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
|
|
return new ManageRegionResponse(regionId, targetResponse, budgetResponse, agencyMapResponse, taxRuleResponse);
|
|
}
|
|
|
|
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
|
|
{
|
|
var region = await repo.GetByIdAsync(regionId);
|
|
if (region is null || !region.IsActive) return null;
|
|
|
|
var routes = await repo.GetRoutesAsync(regionId);
|
|
var stores = await repo.GetStoresByRouteIdsAsync(routes.Select(r => r.Id).ToList());
|
|
var routeItems = routes
|
|
.Select(r => new RegionHierarchyRouteItem(
|
|
r.Id,
|
|
r.RouteName,
|
|
stores.Where(s => s.FK_RouteId == r.Id).Select(s => s.Id).ToList()))
|
|
.ToList();
|
|
return new RegionHierarchyResponse(region.Id, region.Name, routeItems);
|
|
}
|
|
|
|
public async Task<RegionDashboardResponse?> GetDashboardAsync(Guid regionId, string period)
|
|
{
|
|
var region = await repo.GetByIdAsync(regionId);
|
|
if (region is null || !region.IsActive) return null;
|
|
|
|
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
|
|
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
|
|
var normalizedPeriod = periodStart.ToString("yyyy-MM");
|
|
var periodEndExclusive = periodStart.AddMonths(1);
|
|
|
|
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
|
|
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
|
|
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
|
|
var taxRule = await repo.GetTaxRuleAsync(regionId);
|
|
var agencyMaps = await repo.GetAgencyMapsForRegionAsync(regionId);
|
|
|
|
return new RegionDashboardResponse(
|
|
regionId,
|
|
normalizedPeriod,
|
|
target?.TargetValue,
|
|
budget?.BudgetAmount,
|
|
orders.Count,
|
|
orders.Sum(o => o.TotalAmount),
|
|
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt),
|
|
taxRule?.RuleConfig,
|
|
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
|
|
}
|
|
|
|
public async Task<List<RegionManageEntryResponse>> GetManageEntriesAsync()
|
|
{
|
|
var regionNames = (await repo.GetAllAsync()).ToDictionary(r => r.Id, r => r.Name);
|
|
string RegionName(Guid id) => regionNames.TryGetValue(id, out var name) ? name : "Unknown";
|
|
|
|
var entries = new List<RegionManageEntryResponse>();
|
|
|
|
entries.AddRange((await repo.GetAllTargetsAsync()).Select(t =>
|
|
new RegionManageEntryResponse(t.Id, t.FK_RegionId, RegionName(t.FK_RegionId), RegionManageEntryType.Target, t.Period, t.TargetValue, null, null, t.CreatedAt)));
|
|
|
|
entries.AddRange((await repo.GetAllBudgetsAsync()).Select(b =>
|
|
new RegionManageEntryResponse(b.Id, b.FK_RegionId, RegionName(b.FK_RegionId), RegionManageEntryType.Budget, b.Period, b.BudgetAmount, null, null, b.CreatedAt)));
|
|
|
|
entries.AddRange((await repo.GetAllTaxRulesAsync()).Select(r =>
|
|
new RegionManageEntryResponse(r.Id, r.FK_RegionId, RegionName(r.FK_RegionId), RegionManageEntryType.TaxRule, null, null, r.RuleConfig, null, r.CreatedAt)));
|
|
|
|
entries.AddRange((await repo.GetAllAgencyMapsAsync()).Select(m =>
|
|
new RegionManageEntryResponse(m.Id, m.FK_RegionId, RegionName(m.FK_RegionId), RegionManageEntryType.AgencyMap, null, null, null, m.FK_AgencyUserId, m.CreatedAt)));
|
|
|
|
return entries.OrderByDescending(e => e.CreatedAt).ToList();
|
|
}
|
|
|
|
public async Task<bool> DeleteManageEntryAsync(RegionManageEntryType entryType, Guid id, CancellationToken ct = default)
|
|
{
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
bool found;
|
|
switch (entryType)
|
|
{
|
|
case RegionManageEntryType.Target:
|
|
var target = await repo.GetTargetByIdAsync(id);
|
|
found = target is not null;
|
|
if (target is not null) repo.RemoveTarget(target);
|
|
break;
|
|
case RegionManageEntryType.Budget:
|
|
var budget = await repo.GetBudgetByIdAsync(id);
|
|
found = budget is not null;
|
|
if (budget is not null) repo.RemoveBudget(budget);
|
|
break;
|
|
case RegionManageEntryType.TaxRule:
|
|
var rule = await repo.GetTaxRuleByIdAsync(id);
|
|
found = rule is not null;
|
|
if (rule is not null) repo.RemoveTaxRule(rule);
|
|
break;
|
|
case RegionManageEntryType.AgencyMap:
|
|
var map = await repo.GetAgencyMapByIdAsync(id);
|
|
found = map is not null;
|
|
if (map is not null) repo.RemoveAgencyMap(map);
|
|
break;
|
|
default:
|
|
found = false;
|
|
break;
|
|
}
|
|
|
|
if (!found)
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
return false;
|
|
}
|
|
|
|
await uow.CommitAsync(ct);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
}
|
|
}
|