Add region code

This commit is contained in:
2026-10-06 15:09:22 +05:30
parent edb5fddc3a
commit c52ecfdebc
11 changed files with 251 additions and 10 deletions
@@ -18,6 +18,7 @@ public class RegionServiceTests
public RegionServiceTests() public RegionServiceTests()
{ {
_currentUser.Setup(u => u.RoleLevel).Returns(60); _currentUser.Setup(u => u.RoleLevel).Returns(60);
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object); _sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
} }
@@ -148,6 +149,123 @@ public class RegionServiceTests
_uow.Verify(u => u.CommitAsync(default), Times.Once); _uow.Verify(u => u.CommitAsync(default), Times.Once);
} }
// Region code
[Fact]
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0001", result.Code);
}
[Fact]
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0010", result.Code);
}
[Fact]
public async Task CreateAsync_StoresProvidedCode_AsEntered()
{
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
Assert.Equal("nth-1", result.Code);
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
}
[Fact]
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
{
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task GetNextCodeAsync_ReturnsSuggestion()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
var result = await _sut.GetNextCodeAsync();
Assert.Equal("REG-0004", result.Code);
}
[Fact]
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
Assert.Equal("north-1", result!.Code);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
Assert.Equal("REG-0001", result!.Code);
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
}
[Fact]
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task UpdateAsync_AllowsResubmittingOwnCode()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
Assert.Equal("REG-0001", result!.Code);
}
[Fact]
public async Task GetByIdAsync_ReturnsCode()
{
var region = ActiveRegion();
region.Code = "REG-0007";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.GetByIdAsync(region.Id);
Assert.Equal("REG-0007", result!.Code);
}
// DeleteAsync // DeleteAsync
[Fact] [Fact]
+9
View File
@@ -544,6 +544,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Regional --- // --- Regional ---
modelBuilder.Entity<Region>()
.Property(r => r.Code)
.HasMaxLength(50);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Code)
.IsUnique();
modelBuilder.Entity<RegionalTarget>() modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period }) .HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique(); .IsUnique();
+2 -2
View File
@@ -84,8 +84,8 @@ public static class MasterDataSeed
); );
modelBuilder.Entity<Region>().HasData( modelBuilder.Entity<Region>().HasData(
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true }, new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true } new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
); );
modelBuilder.Entity<Models.Route>().HasData( modelBuilder.Entity<Models.Route>().HasData(
@@ -4,6 +4,8 @@ public interface IRegionRepository
{ {
Task<Models.Region?> GetByIdAsync(Guid id); Task<Models.Region?> GetByIdAsync(Guid id);
Task<List<Models.Region>> GetAllAsync(); Task<List<Models.Region>> GetAllAsync();
Task<List<string>> GetAllCodesAsync();
Task<bool> CodeExistsAsync(string code, Guid? excludeId = null);
Task AddAsync(Models.Region entity); Task AddAsync(Models.Region entity);
void Update(Models.Region entity); void Update(Models.Region entity);
void Remove(Models.Region entity); void Remove(Models.Region entity);
@@ -10,6 +10,11 @@ public class RegionController(RegionService service) : ControllerBase
public async Task<IActionResult> GetAll() => public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync()); Ok(await service.GetAllAsync());
// Suggested code for the Add Region form; the user may override it.
[HttpGet("next-code")]
public async Task<IActionResult> GetNextCode() =>
Ok(await service.GetNextCodeAsync());
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id) public async Task<IActionResult> GetById(Guid id)
{ {
+7 -3
View File
@@ -1,10 +1,14 @@
namespace Distribution.Features.Region; namespace Distribution.Features.Region;
public record CreateRegionRequest(string Name); // Code is optional: blank/null auto-generates the next REG-#### code; otherwise stored as entered (trimmed).
public record CreateRegionRequest(string Name, string? Code = null);
public record UpdateRegionRequest(string Name); public record NextRegionCodeResponse(string Code);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt); // Code is auto-generated on create; on update it is optional (null/blank keeps the current code).
public record UpdateRegionRequest(string Name, string? Code = null);
public record RegionResponse(Guid Id, string Code, string Name, bool IsActive, DateTime CreatedAt);
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue); public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
@@ -11,6 +11,13 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Region>> GetAllAsync() => public async Task<List<Models.Region>> GetAllAsync() =>
await context.Regions.ToListAsync(); await context.Regions.ToListAsync();
// Includes soft-deleted regions: the unique index on Code doesn't know about IsActive.
public async Task<List<string>> GetAllCodesAsync() =>
await context.Regions.Select(r => r.Code).ToListAsync();
public async Task<bool> CodeExistsAsync(string code, Guid? excludeId = null) =>
await context.Regions.AnyAsync(r => r.Code == code && (excludeId == null || r.Id != excludeId));
public async Task AddAsync(Models.Region entity) => public async Task AddAsync(Models.Region entity) =>
await context.Regions.AddAsync(entity); await context.Regions.AddAsync(entity);
+36 -5
View File
@@ -6,6 +6,7 @@ namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess) public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{ {
private const int RegionalAgencyRoleLevel = 60; private const int RegionalAgencyRoleLevel = 60;
private const string CodePrefix = "REG-";
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once // 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. // the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
@@ -14,19 +15,42 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
public async Task<List<RegionResponse>> GetAllAsync() => public async Task<List<RegionResponse>> GetAllAsync() =>
(await repo.GetAllAsync()) (await repo.GetAllAsync())
.Where(r => r.IsActive) .Where(r => r.IsActive)
.Select(r => new RegionResponse(r.Id, r.Name, r.IsActive, r.CreatedAt)) .Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt))
.ToList(); .ToList();
public async Task<RegionResponse?> GetByIdAsync(Guid id) public async Task<RegionResponse?> GetByIdAsync(Guid id)
{ {
var region = await repo.GetByIdAsync(id); var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null; if (region is null || !region.IsActive) return null;
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt); 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) public async Task<RegionResponse> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
{ {
var region = new Models.Region { Name = request.Name }; 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); await uow.BeginAsync(ct);
try try
{ {
@@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct); await uow.RollbackAsync(ct);
throw; throw;
} }
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt); 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) public async Task<RegionResponse?> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
@@ -46,6 +70,13 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
var region = await repo.GetByIdAsync(id); var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null; if (region is null || !region.IsActive) return null;
region.Name = request.Name; 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); await uow.BeginAsync(ct);
try try
{ {
@@ -57,7 +88,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct); await uow.RollbackAsync(ct);
throw; throw;
} }
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt); return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
} }
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default) public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -0,0 +1,54 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDBContext))]
[Migration("20261006100000_AddRegionCode")]
public partial class AddRegionCode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Code",
table: "Regions",
type: "varchar(50)",
maxLength: 50,
nullable: false,
defaultValue: "");
// Backfill existing rows (seeded regions first) with REG-0001, REG-0002, ... before the unique index.
migrationBuilder.Sql(@"
UPDATE `Regions` r
JOIN (
SELECT `Id`, ROW_NUMBER() OVER (
ORDER BY CASE WHEN `Id` LIKE '24000000-%' THEN 0 ELSE 1 END, `CreatedAt`, `Id`) AS rn
FROM `Regions`
) x ON r.`Id` = x.`Id`
SET r.`Code` = CONCAT('REG-', LPAD(x.rn, 4, '0'));");
migrationBuilder.CreateIndex(
name: "IX_Regions_Code",
table: "Regions",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Regions_Code",
table: "Regions");
migrationBuilder.DropColumn(
name: "Code",
table: "Regions");
}
}
}
@@ -1474,6 +1474,11 @@ namespace Distribution.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -1486,6 +1491,9 @@ namespace Distribution.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Regions"); b.ToTable("Regions");
b.HasData( b.HasData(
@@ -1494,6 +1502,7 @@ namespace Distribution.Migrations
Id = new Guid("24000000-0000-0000-0000-000000000001"), Id = new Guid("24000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true, IsActive = true,
Code = "REG-0001",
Name = "Western Province" Name = "Western Province"
}, },
new new
@@ -1501,6 +1510,7 @@ namespace Distribution.Migrations
Id = new Guid("24000000-0000-0000-0000-000000000002"), Id = new Guid("24000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true, IsActive = true,
Code = "REG-0002",
Name = "Central Province" Name = "Central Province"
}); });
}); });
+1
View File
@@ -2,6 +2,7 @@
{ {
public class Region : Template public class Region : Template
{ {
public string Code { get; set; } = string.Empty;
public string Name { get; set; } public string Name { get; set; }
} }
} }