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
+9
View File
@@ -544,6 +544,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Regional ---
modelBuilder.Entity<Region>()
.Property(r => r.Code)
.HasMaxLength(50);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Code)
.IsUnique();
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
+2 -2
View File
@@ -84,8 +84,8 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Region>().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<Models.Route>().HasData(
@@ -4,6 +4,8 @@ public interface IRegionRepository
{
Task<Models.Region?> GetByIdAsync(Guid id);
Task<List<Models.Region>> GetAllAsync();
Task<List<string>> GetAllCodesAsync();
Task<bool> CodeExistsAsync(string code, Guid? excludeId = null);
Task AddAsync(Models.Region entity);
void Update(Models.Region entity);
void Remove(Models.Region entity);
@@ -10,6 +10,11 @@ public class RegionController(RegionService service) : ControllerBase
public async Task<IActionResult> GetAll() =>
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}")]
public async Task<IActionResult> GetById(Guid id)
{
+7 -3
View File
@@ -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);
@@ -11,6 +11,13 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Region>> GetAllAsync() =>
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) =>
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)
{
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<List<RegionResponse>> 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<RegionResponse?> 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<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)
{
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<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);
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<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()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime>("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"
});
});
+1
View File
@@ -2,6 +2,7 @@
{
public class Region : Template
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; }
}
}