From c52ecfdebc03429fee2497338644585eaddf21a8 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 6 Oct 2026 15:09:22 +0530 Subject: [PATCH] Add region code --- .../Services/RegionServiceTests.cs | 118 ++++++++++++++++++ Distribution/Data/AppDBContext.cs | 9 ++ Distribution/Data/Seed/MasterDataSeed.cs | 4 +- .../Features/Region/IRegionRepository.cs | 2 + .../Features/Region/RegionController.cs | 5 + Distribution/Features/Region/RegionDTOs.cs | 10 +- .../Features/Region/RegionRepository.cs | 7 ++ Distribution/Features/Region/RegionService.cs | 41 +++++- .../20261006100000_AddRegionCode.cs | 54 ++++++++ .../Migrations/AppDBContextModelSnapshot.cs | 10 ++ Distribution/Models/Region.cs | 1 + 11 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 Distribution/Migrations/20261006100000_AddRegionCode.cs diff --git a/Distribution.Tests/Services/RegionServiceTests.cs b/Distribution.Tests/Services/RegionServiceTests.cs index 358cca7..07159f0 100644 --- a/Distribution.Tests/Services/RegionServiceTests.cs +++ b/Distribution.Tests/Services/RegionServiceTests.cs @@ -18,6 +18,7 @@ public class RegionServiceTests public RegionServiceTests() { _currentUser.Setup(u => u.RoleLevel).Returns(60); + _repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]); _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); } + // 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(() => + _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(), It.IsAny()), 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(() => + _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 [Fact] diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index 26ba8f7..afab016 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -544,6 +544,15 @@ public class AppDBContext(DbContextOptions options) : DbContext(op // --- Regional --- + modelBuilder.Entity() + .Property(r => r.Code) + .HasMaxLength(50); + + modelBuilder.Entity() + .HasIndex(r => r.Code) + .IsUnique(); + + modelBuilder.Entity() .HasIndex(t => new { t.FK_RegionId, t.Period }) .IsUnique(); diff --git a/Distribution/Data/Seed/MasterDataSeed.cs b/Distribution/Data/Seed/MasterDataSeed.cs index 53d8a40..121ee2b 100644 --- a/Distribution/Data/Seed/MasterDataSeed.cs +++ b/Distribution/Data/Seed/MasterDataSeed.cs @@ -84,8 +84,8 @@ public static class MasterDataSeed ); modelBuilder.Entity().HasData( - new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true }, - new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true } + new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true }, + new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true } ); modelBuilder.Entity().HasData( diff --git a/Distribution/Features/Region/IRegionRepository.cs b/Distribution/Features/Region/IRegionRepository.cs index 36b8c95..33dbfb9 100644 --- a/Distribution/Features/Region/IRegionRepository.cs +++ b/Distribution/Features/Region/IRegionRepository.cs @@ -4,6 +4,8 @@ public interface IRegionRepository { Task GetByIdAsync(Guid id); Task> GetAllAsync(); + Task> GetAllCodesAsync(); + Task CodeExistsAsync(string code, Guid? excludeId = null); Task AddAsync(Models.Region entity); void Update(Models.Region entity); void Remove(Models.Region entity); diff --git a/Distribution/Features/Region/RegionController.cs b/Distribution/Features/Region/RegionController.cs index c21dbd9..755a745 100644 --- a/Distribution/Features/Region/RegionController.cs +++ b/Distribution/Features/Region/RegionController.cs @@ -10,6 +10,11 @@ public class RegionController(RegionService service) : ControllerBase public async Task GetAll() => Ok(await service.GetAllAsync()); + // Suggested code for the Add Region form; the user may override it. + [HttpGet("next-code")] + public async Task GetNextCode() => + Ok(await service.GetNextCodeAsync()); + [HttpGet("{id:guid}")] public async Task GetById(Guid id) { diff --git a/Distribution/Features/Region/RegionDTOs.cs b/Distribution/Features/Region/RegionDTOs.cs index 896f0f2..c21a594 100644 --- a/Distribution/Features/Region/RegionDTOs.cs +++ b/Distribution/Features/Region/RegionDTOs.cs @@ -1,10 +1,14 @@ 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); diff --git a/Distribution/Features/Region/RegionRepository.cs b/Distribution/Features/Region/RegionRepository.cs index c760c31..b06b6e8 100644 --- a/Distribution/Features/Region/RegionRepository.cs +++ b/Distribution/Features/Region/RegionRepository.cs @@ -11,6 +11,13 @@ public class RegionRepository(AppDBContext context) : IRegionRepository public async Task> GetAllAsync() => await context.Regions.ToListAsync(); + // Includes soft-deleted regions: the unique index on Code doesn't know about IsActive. + public async Task> GetAllCodesAsync() => + await context.Regions.Select(r => r.Code).ToListAsync(); + + public async Task 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) => await context.Regions.AddAsync(entity); diff --git a/Distribution/Features/Region/RegionService.cs b/Distribution/Features/Region/RegionService.cs index ce26dc8..6c8bdc7 100644 --- a/Distribution/Features/Region/RegionService.cs +++ b/Distribution/Features/Region/RegionService.cs @@ -6,6 +6,7 @@ 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. @@ -14,19 +15,42 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser public async Task> GetAllAsync() => (await repo.GetAllAsync()) .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(); public async Task GetByIdAsync(Guid id) { var region = await repo.GetByIdAsync(id); 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 GetNextCodeAsync() => new(await GenerateCodeAsync()); + + private async Task 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 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); try { @@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser await uow.RollbackAsync(ct); 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 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); 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 { @@ -57,7 +88,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser await uow.RollbackAsync(ct); 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 DeleteAsync(Guid id, CancellationToken ct = default) diff --git a/Distribution/Migrations/20261006100000_AddRegionCode.cs b/Distribution/Migrations/20261006100000_AddRegionCode.cs new file mode 100644 index 0000000..376f7d6 --- /dev/null +++ b/Distribution/Migrations/20261006100000_AddRegionCode.cs @@ -0,0 +1,54 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Distribution.Migrations +{ + /// + [DbContext(typeof(AppDBContext))] + [Migration("20261006100000_AddRegionCode")] + public partial class AddRegionCode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Regions_Code", + table: "Regions"); + + migrationBuilder.DropColumn( + name: "Code", + table: "Regions"); + } + } +} diff --git a/Distribution/Migrations/AppDBContextModelSnapshot.cs b/Distribution/Migrations/AppDBContextModelSnapshot.cs index fae423d..33c22c4 100644 --- a/Distribution/Migrations/AppDBContextModelSnapshot.cs +++ b/Distribution/Migrations/AppDBContextModelSnapshot.cs @@ -1474,6 +1474,11 @@ namespace Distribution.Migrations .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + b.Property("CreatedAt") .HasColumnType("datetime(6)"); @@ -1486,6 +1491,9 @@ namespace Distribution.Migrations b.HasKey("Id"); + b.HasIndex("Code") + .IsUnique(); + b.ToTable("Regions"); b.HasData( @@ -1494,6 +1502,7 @@ namespace Distribution.Migrations Id = new Guid("24000000-0000-0000-0000-000000000001"), CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), IsActive = true, + Code = "REG-0001", Name = "Western Province" }, new @@ -1501,6 +1510,7 @@ namespace Distribution.Migrations Id = new Guid("24000000-0000-0000-0000-000000000002"), CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), IsActive = true, + Code = "REG-0002", Name = "Central Province" }); }); diff --git a/Distribution/Models/Region.cs b/Distribution/Models/Region.cs index 877ee14..2800aee 100644 --- a/Distribution/Models/Region.cs +++ b/Distribution/Models/Region.cs @@ -2,6 +2,7 @@ { public class Region : Template { + public string Code { get; set; } = string.Empty; public string Name { get; set; } } }