Compare commits

...

25 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna 76ca9f874d ride reconcilation with driver profile update 2026-08-31 12:14:30 +05:30
DeepnaPooja 75b5035354 Merge pull request 'Feat/region mngt' (#16) from feat/region_mngt into dev
Reviewed-on: #16
2026-08-31 05:47:39 +00:00
DeepnaPooja c0432a448f Document route-to-region assignment and applied migration
Notes the new PUT /api/routes/{id}/region endpoint as a stand-in for
the still-blocked assignAreaToRegion, and removes the stale "migration
pending" caveat now that AddRegionManagement has been applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:11:25 +05:30
DeepnaPooja bfbfe352ee Add dedicated endpoint to assign a route to a region
PUT /api/routes/{id}/region assigns/reassigns an existing route to a
region directly, validating the target region exists and is active.
Stands in for the blocked assignAreaToRegion until an Area entity
exists between Region and Route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:11:15 +05:30
DeepnaPooja 320da0e1c9 Add and apply EF Core migration for region management tables
Creates RegionAgencyMaps, RegionalBudgets, RegionalTargets, and
RegionalTaxRules with their unique indexes and foreign keys, and
applies the migration to the shared MySQL database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 10:38:59 +05:30
DeepnaPooja f0d7f939ef Update mainPlan.md status table for Region Management
The summary table still said "Not Started" for step 10 after the
detailed section below it was updated to reflect the implemented
target/budget/agency-map/tax-rule/hierarchy/dashboard endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:53:59 +05:30
DeepnaPooja 8b84c8aeb3 Document the implemented region management endpoints
Updates mainPlan.md's Region Management table, 10_region_management.md's
status/open-questions/implementation-notes sections, and
BACKEND_API_REFERENCE.md's Regions section to reflect the newly added
target/budget/agency-map/tax-rule/hierarchy/dashboard endpoints, and
flags that the EF Core migration for the new tables is still pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:51:46 +05:30
DeepnaPooja 9febae6d63 Add region management dialog for targets, budgets, tax rules & agencies
RegionManageSheet lets a manager set a region's target/budget for a
period, save a tax rule config, map a RegionalAgency user to the
region, and view its route/store hierarchy and dashboard stats. Wired
into the regions table as a new row action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:46:05 +05:30
DeepnaPooja df27d58089 Add frontend types and API client methods for region management
Adds RegionalTargetResponse, RegionalBudgetResponse,
RegionAgencyMapResponse, RegionalTaxRuleResponse,
RegionHierarchyResponse, and RegionDashboardResponse types, plus
setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard methods
on the regions API client, mirroring the new backend endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:55 +05:30
DeepnaPooja 4e12e9f8f1 Update RegionServiceTests for the new RegionService constructor
RegionService now depends on ICurrentUserService and IScopeAccessService
for the target/budget/tax-rule/agency scope checks; update the existing
CRUD test setup to mock them accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:43 +05:30
DeepnaPooja 39eab2e3a2 Add region target/budget/tax-rule/agency-map/hierarchy/dashboard APIs
Extends RegionService, RegionRepository, and RegionController with the
endpoints from mainPlan.md sec 2 beyond plain CRUD: setRegionalTarget,
setRegionalBudget, mapAgencyToRegion, setRegionalTaxRule,
getRegionHierarchy, and getRegionDashboard. Target/budget/tax-rule
mutation is gated to users with region scope at RegionalManager level
or RegionalAgency level across all regions; mapAgencyToRegion links an
existing User holding the RegionalAgency role rather than introducing
a separate agency entity, matching the convention already used by
Claim.FK_AgencyUserId elsewhere in the codebase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:35 +05:30
DeepnaPooja 889ca0795d Add data model for region targets, budgets, agency mapping, tax rules
Introduces RegionalTarget, RegionalBudget, RegionAgencyMap, and
RegionalTaxRule entities plus their DbSets and EF constraints, laying
the groundwork for the "beyond CRUD" region management features.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:44:12 +05:30
Kalana B. Thilakarathna a7801a2bc2 Merge branch 'production' into dev 2026-08-29 21:03:56 +05:30
Kalana B. Thilakarathna f4db83654c Implement GRN and Purchase Order sheets with form handling and data fetching 2026-08-29 21:03:31 +05:30
Kalana B. Thilakarathna d414ec8e38 Merge branch 'dev' into production 2026-08-26 20:20:00 +05:30
Kalana B. Thilakarathna dc7e7a70fc Add FlatPaymentController with RESTful payment endpoints
Introduced FlatPaymentController under Distribution.Features.Payment, providing GET and POST endpoints at /api/payments. Supports listing, retrieving, and creating payments with filtering and error handling. Business logic is delegated to PaymentService.
2026-08-26 20:18:58 +05:30
Kalana B. Thilakarathna 20b82a2734 Merge branch 'dev' into production 2026-08-25 23:48:06 +05:30
Kalana B. Thilakarathna 83d3272784 frontend fully integreated with the backend 2026-08-25 23:47:33 +05:30
DeepnaPooja d6cd9ccd08 Merge pull request 'Dev' (#15) from dev into production
Reviewed-on: #15
2026-08-25 17:24:08 +00:00
DeepnaPooja 1bee5f91c6 Merge branches 'feat/authentication' and 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev 2026-08-25 22:46:27 +05:30
kalana_Thilakarathna 3268a50c3e Merge pull request 'Production' (#13) from production into dev
Reviewed-on: #13
2026-08-25 10:56:00 +00:00
Kalana B. Thilakarathna f1c036d1d8 frontend initiatewith shadcn uis 2026-08-25 16:20:50 +05:30
Kalana B. Thilakarathna 92fef1dfa8 Merge branch 'dev' into production 2026-08-25 16:12:06 +05:30
Kalana B. Thilakarathna bb33928661 auth clean up 2026-08-25 16:11:37 +05:30
DeepnaPooja b279a121ad Merge pull request 'Dev' (#12) from dev into production
Reviewed-on: #12
2026-08-25 10:15:19 +00:00
331 changed files with 42514 additions and 520 deletions
@@ -1,4 +1,5 @@
using Distribution.Features.Region;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Moq;
@@ -10,11 +11,14 @@ public class RegionServiceTests
{
private readonly Mock<IRegionRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<ICurrentUserService> _currentUser = new();
private readonly Mock<IScopeAccessService> _scopeAccess = new();
private readonly RegionService _sut;
public RegionServiceTests()
{
_sut = new RegionService(_repo.Object, _uow.Object);
_currentUser.Setup(u => u.RoleLevel).Returns(60);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
}
private static Region ActiveRegion(string name = "North") =>
+46
View File
@@ -56,6 +56,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Claim> Claims { get; set; }
public DbSet<SupplierClaim> SupplierClaims { get; set; }
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
public DbSet<RegionalTarget> RegionalTargets { get; set; }
public DbSet<RegionalBudget> RegionalBudgets { get; set; }
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RideItemCount> RideItemCounts { get; set; }
public DbSet<RideVariance> RideVariances { get; set; }
public DbSet<RideCloseSummary> RideCloseSummaries { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -373,6 +380,45 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
modelBuilder.Entity<RegionalBudget>()
.HasIndex(b => new { b.FK_RegionId, b.Period })
.IsUnique();
modelBuilder.Entity<RegionAgencyMap>()
.HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId })
.IsUnique();
modelBuilder.Entity<RegionAgencyMap>()
.HasOne(m => m.AgencyUser)
.WithMany()
.HasForeignKey(m => m.FK_AgencyUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RegionalTaxRule>()
.HasIndex(r => r.FK_RegionId)
.IsUnique();
modelBuilder.Entity<RideVariance>()
.HasOne(v => v.ResolvedBy)
.WithMany()
.HasForeignKey(v => v.FK_ResolvedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<RideCloseSummary>()
.HasIndex(s => s.FK_RideId)
.IsUnique();
modelBuilder.Entity<RideCloseSummary>()
.HasOne(s => s.ClosedByUser)
.WithMany()
.HasForeignKey(s => s.FK_ClosedByUserId)
.OnDelete(DeleteBehavior.Restrict);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -0,0 +1,20 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Typed HttpClient that proxies requests to the external Auth Service.
/// Registered via AddHttpClient&lt;AuthServiceClient&gt; in Program.cs.
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
///
/// TODO: Implement methods once Auth Service contract is agreed.
/// </summary>
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
{
// TODO: implement proxy methods, e.g.:
//
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
// {
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
// if (!response.IsSuccessStatusCode) return null;
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
// }
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.Auth;
public class AuthServiceOptions
{
public const string SectionName = "AuthService";
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
public string BaseUrl { get; set; } = string.Empty;
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
}
@@ -0,0 +1,16 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Connector to the external Auth Service. This backend proxies all auth
/// traffic from the Next.js frontend through to the Auth Service — the
/// frontend never calls the Auth Service directly.
///
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
/// </summary>
public interface IAuthServiceClient
{
// TODO: define methods based on Auth Service contract, e.g.:
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
// Task LogoutAsync(string token, CancellationToken ct);
}
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Brand;
[ApiController]
[Route("api/brands")]
[Authorize]
public class BrandController(BrandService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -1,19 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Category;
[ApiController]
[Authorize]
public class CategoryController(CategoryService service) : ControllerBase
{
[HttpGet("api/categories")]
[Authorize(Policy = "categories.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/categories/{id:guid}")]
[Authorize(Policy = "categories.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPost("api/categories")]
[Authorize(Policy = "categories.create")]
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPut("api/categories/{id:guid}")]
[Authorize(Policy = "categories.update")]
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpDelete("api/categories/{id:guid}")]
[Authorize(Policy = "categories.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
[Authorize(Policy = "categories.create")]
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
{
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
@@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPut("api/subcategories/{id:guid}")]
[Authorize(Policy = "categories.update")]
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateSubCategoryAsync(id, request, ct);
@@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpDelete("api/subcategories/{id:guid}")]
[Authorize(Policy = "categories.delete")]
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteSubCategoryAsync(id, ct);
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
[ApiController]
[Authorize]
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims/{id:guid}")]
@@ -27,7 +25,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims/{id:guid}/settle")]
[Authorize(Policy = "claims.settle")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -1,10 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Authorize]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
@@ -21,6 +19,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
@@ -1,28 +0,0 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverComplianceConfig;
[ApiController]
[Route("api/driver-compliance-config")]
[Authorize]
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "compliance.manage")]
public async Task<IActionResult> Get()
{
var result = await service.GetAsync();
return result is null ? NoContent() : Ok(result);
}
[HttpPut]
[Authorize(Policy = "compliance.manage")]
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var result = await service.UpsertAsync(request, userId, ct);
return Ok(result);
}
}
@@ -1,12 +0,0 @@
namespace Distribution.Features.DriverComplianceConfig;
public record DriverComplianceConfigResponse(
Guid Id,
bool EnforceLicenseExpiry,
bool EnforceMedicalClearance,
DateTime UpdatedAt,
Guid UpdatedBy);
public record UpdateDriverComplianceConfigRequest(
bool EnforceLicenseExpiry,
bool EnforceMedicalClearance);
@@ -1,16 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DriverComplianceConfig;
public class DriverComplianceConfigRepository(AppDBContext context) : IDriverComplianceConfigRepository
{
public async Task<Models.DriverComplianceConfig?> GetAsync() =>
await context.DriverComplianceConfigs.FirstOrDefaultAsync();
public async Task AddAsync(Models.DriverComplianceConfig config) =>
await context.DriverComplianceConfigs.AddAsync(config);
public void Update(Models.DriverComplianceConfig config) =>
context.DriverComplianceConfigs.Update(config);
}
@@ -1,62 +0,0 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.DriverComplianceConfig;
public class DriverComplianceConfigService(IDriverComplianceConfigRepository repo, IUnitOfWork uow)
{
public async Task<DriverComplianceConfigResponse?> GetAsync()
{
var config = await repo.GetAsync();
return config is null
? null
: new DriverComplianceConfigResponse(
config.Id,
config.EnforceLicenseExpiry,
config.EnforceMedicalClearance,
config.UpdatedAt,
config.UpdatedBy);
}
public async Task<DriverComplianceConfigResponse> UpsertAsync(UpdateDriverComplianceConfigRequest request, Guid updatedBy, CancellationToken ct = default)
{
var config = await repo.GetAsync();
await uow.BeginAsync(ct);
try
{
if (config is null)
{
config = new global::Distribution.Models.DriverComplianceConfig
{
EnforceLicenseExpiry = request.EnforceLicenseExpiry,
EnforceMedicalClearance = request.EnforceMedicalClearance,
UpdatedBy = updatedBy,
UpdatedAt = DateTime.Now
};
await repo.AddAsync(config);
}
else
{
config.EnforceLicenseExpiry = request.EnforceLicenseExpiry;
config.EnforceMedicalClearance = request.EnforceMedicalClearance;
config.UpdatedBy = updatedBy;
config.UpdatedAt = DateTime.Now;
repo.Update(config);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new DriverComplianceConfigResponse(
config.Id,
config.EnforceLicenseExpiry,
config.EnforceMedicalClearance,
config.UpdatedAt,
config.UpdatedBy);
}
}
@@ -1,8 +0,0 @@
namespace Distribution.Features.DriverComplianceConfig;
public interface IDriverComplianceConfigRepository
{
Task<Models.DriverComplianceConfig?> GetAsync();
Task AddAsync(Models.DriverComplianceConfig config);
void Update(Models.DriverComplianceConfig config);
}
@@ -1,15 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
[ApiController]
[Route("api/driver-profiles")]
[Authorize]
public class DriverProfileController(DriverProfileService service) : ControllerBase
public class DriverProfileController(
DriverProfileService service,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "driver_profiles.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(page, pageSize);
@@ -17,7 +17,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "driver_profiles.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -25,15 +24,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
}
[HttpPost]
[Authorize(Policy = "driver_profiles.create")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "driver_profiles.update")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -41,10 +49,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "driver_profiles.deactivate")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var success = await service.DeactivateAsync(id, ct);
return success ? NoContent() : NotFound();
}
[HttpGet("compliance")]
public async Task<IActionResult> GetCompliance()
{
var result = await service.GetComplianceConfigAsync();
return result is null ? NoContent() : Ok(result);
}
[HttpPut("compliance")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var result = await service.UpsertComplianceConfigAsync(request, userId, ct);
return Ok(result);
}
}
@@ -11,7 +11,7 @@ public record DriverProfileResponse(
DateTime CreatedAt);
public record CreateDriverProfileRequest(
Guid FK_UserId,
Guid UserId,
string LicenseNumber,
DateTime LicenseExpiry,
DateTime MedicalClearanceExpiry);
@@ -20,3 +20,14 @@ public record UpdateDriverProfileRequest(
string LicenseNumber,
DateTime LicenseExpiry,
DateTime MedicalClearanceExpiry);
public record DriverComplianceConfigResponse(
Guid Id,
bool EnforceLicenseExpiry,
bool EnforceMedicalClearance,
DateTime UpdatedAt,
Guid UpdatedBy);
public record UpdateDriverComplianceConfigRequest(
bool EnforceLicenseExpiry,
bool EnforceMedicalClearance);
@@ -37,4 +37,13 @@ public class DriverProfileRepository(AppDBContext context) : IDriverProfileRepos
profile.IsActive = false;
context.DriverProfiles.Update(profile);
}
public async Task<global::Distribution.Models.DriverComplianceConfig?> GetComplianceConfigAsync() =>
await context.DriverComplianceConfigs.FirstOrDefaultAsync();
public async Task AddComplianceConfigAsync(global::Distribution.Models.DriverComplianceConfig config) =>
await context.DriverComplianceConfigs.AddAsync(config);
public void UpdateComplianceConfig(global::Distribution.Models.DriverComplianceConfig config) =>
context.DriverComplianceConfigs.Update(config);
}
@@ -1,9 +1,10 @@
using Distribution.Features.Stock;
using Distribution.Features.User;
using Distribution.Infra.UoW;
namespace Distribution.Features.DriverProfile;
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo)
{
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
{
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
{
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
var user = await userRepo.GetByIdAsync(request.UserId);
if (user is null)
throw new KeyNotFoundException($"User {request.UserId} not found");
var existing = await repo.GetByUserIdAsync(request.UserId);
if (existing is not null && existing.IsActive)
throw new InvalidOperationException("An active driver profile already exists for this user");
var profile = new global::Distribution.Models.DriverProfile
{
FK_UserId = request.FK_UserId,
FK_UserId = request.UserId,
LicenseNumber = request.LicenseNumber,
LicenseExpiry = request.LicenseExpiry,
MedicalClearanceExpiry = request.MedicalClearanceExpiry
@@ -93,6 +98,56 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
return true;
}
public async Task<DriverComplianceConfigResponse?> GetComplianceConfigAsync()
{
var config = await repo.GetComplianceConfigAsync();
return config is null ? null
: new DriverComplianceConfigResponse(
config.Id, config.EnforceLicenseExpiry, config.EnforceMedicalClearance,
config.UpdatedAt, config.UpdatedBy);
}
public async Task<DriverComplianceConfigResponse> UpsertComplianceConfigAsync(
UpdateDriverComplianceConfigRequest request, Guid updatedBy, CancellationToken ct = default)
{
var config = await repo.GetComplianceConfigAsync();
await uow.BeginAsync(ct);
try
{
if (config is null)
{
config = new global::Distribution.Models.DriverComplianceConfig
{
EnforceLicenseExpiry = request.EnforceLicenseExpiry,
EnforceMedicalClearance = request.EnforceMedicalClearance,
UpdatedBy = updatedBy,
UpdatedAt = DateTime.Now
};
await repo.AddComplianceConfigAsync(config);
}
else
{
config.EnforceLicenseExpiry = request.EnforceLicenseExpiry;
config.EnforceMedicalClearance = request.EnforceMedicalClearance;
config.UpdatedBy = updatedBy;
config.UpdatedAt = DateTime.Now;
repo.UpdateComplianceConfig(config);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new DriverComplianceConfigResponse(
config.Id, config.EnforceLicenseExpiry, config.EnforceMedicalClearance,
config.UpdatedAt, config.UpdatedBy);
}
private static DriverProfileResponse ToResponse(global::Distribution.Models.DriverProfile p) =>
new(p.Id, p.FK_UserId, p.User?.FullName ?? string.Empty,
p.LicenseNumber, p.LicenseExpiry, p.MedicalClearanceExpiry,
@@ -9,4 +9,8 @@ public interface IDriverProfileRepository
Task AddAsync(global::Distribution.Models.DriverProfile profile);
void Update(global::Distribution.Models.DriverProfile profile);
void Deactivate(global::Distribution.Models.DriverProfile profile);
Task<global::Distribution.Models.DriverComplianceConfig?> GetComplianceConfigAsync();
Task AddComplianceConfigAsync(global::Distribution.Models.DriverComplianceConfig config);
void UpdateComplianceConfig(global::Distribution.Models.DriverComplianceConfig config);
}
+24 -3
View File
@@ -1,13 +1,21 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
[ApiController]
[Authorize]
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? purchaseOrderId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(purchaseOrderId, page, pageSize);
return Ok(result);
}
[HttpGet("api/goods-receipts/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -15,6 +23,19 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
return result is null ? NotFound() : Ok(result);
}
[HttpPost("api/goods-receipts")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
+2
View File
@@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
public record GrnItemResponse(
Guid Id,
Guid PurchaseOrderItemId,
@@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? purchaseOrderId) =>
await context.GoodsReceipts
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.CountAsync();
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
+7
View File
@@ -19,6 +19,13 @@ public class GrnService(
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{
var total = await repo.GetCountAsync(purchaseOrderId);
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
}
public async Task<GrnResponse?> GetByIdAsync(Guid id)
{
var gr = await repo.GetByIdAsync(id);
@@ -3,6 +3,8 @@ namespace Distribution.Features.Grn;
public interface IGrnRepository
{
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize);
Task<int> GetCountAsync(Guid? purchaseOrderId);
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.GoodsReceipt entity);
+1 -9
View File
@@ -1,15 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Item;
[ApiController]
[Route("api/items")]
[Authorize]
public class ItemController(ItemService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? categoryId,
[FromQuery] Guid? subCategoryId,
@@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
{
try
@@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpPut("{id:guid}/uom-conversions")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
{
var result = await service.ReplaceConversionsAsync(id, request, ct);
@@ -1,17 +1,14 @@
using Distribution.Models;
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
[Authorize]
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
@@ -23,7 +20,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -31,7 +27,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
try
@@ -44,7 +39,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPut("{id:guid}/items")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
@@ -57,7 +51,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/confirm-dispatch")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -71,7 +64,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPut("{id:guid}/receipt-items")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
@@ -84,7 +76,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/confirm-receipt")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -98,7 +89,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/resolve")]
[Authorize(Policy = "loading.resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -110,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/dispatch")]
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/receipt")]
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/resolve")]
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchResolveAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -35,3 +35,7 @@ public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
// Combined PATCH endpoints used by the frontend
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
@@ -225,6 +225,98 @@ public class LoadingSheetService(
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
foreach (var input in items)
{
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
sheetItem.QuantityReceived = input.QuantityReceived;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var item in sheet.Items)
{
if (!item.QuantityReceived.HasValue)
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
item.QuantityResolved = item.QuantityReceived.Value;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
[ApiController]
[Route("api/orders")]
[Authorize]
public class OrderController(OrderService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
@@ -30,7 +25,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
try
@@ -42,7 +36,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
try
@@ -54,7 +47,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "orders.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/payments")]
public class FlatPaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? storeId,
[FromQuery] Guid? saleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateFlatPaymentRequest request, CancellationToken ct)
{
try
{
var inner = new CreatePaymentRequest(request.Amount, request.Method);
var result = await service.CreateAsync(request.SaleId, inner, ct);
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -4,6 +4,9 @@ namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<Models.Payment?> GetByIdAsync(Guid id);
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
}
@@ -1,10 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Authorize]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
@@ -4,6 +4,8 @@ namespace Distribution.Features.Payment;
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid SaleId,
@@ -6,6 +6,26 @@ namespace Distribution.Features.Payment;
public class PaymentRepository(AppDBContext context) : IPaymentRepository
{
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
await context.Payments
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.OrderByDescending(p => p.CollectedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
await context.Payments
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.CountAsync();
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive)
@@ -1,4 +1,5 @@
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -14,6 +15,19 @@ public class PaymentService(
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
{
var total = await repo.GetCountAsync(storeId, saleId);
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
{
var payment = await repo.GetByIdAsync(id);
return payment is null ? null : ToResponse(payment);
}
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PriceList;
[ApiController]
[Route("api/price-lists")]
[Authorize]
public class PriceListController(PriceListService service) : ControllerBase
{
[HttpGet]
@@ -1,13 +1,11 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Po;
[ApiController]
[Route("api/purchase-orders")]
[Authorize]
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PurchaseReturn;
[ApiController]
[Authorize]
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/purchase-returns/{id:guid}")]
@@ -0,0 +1,21 @@
using Distribution.Models;
namespace Distribution.Features.Reconciliation;
public interface IReconciliationRepository
{
Task AddItemCountAsync(RideItemCount count);
Task<List<RideItemCount>> GetItemCountsByRideAsync(Guid rideId);
Task AddVarianceAsync(RideVariance variance);
Task<List<RideVariance>> GetVariancesByRideAsync(Guid rideId);
Task<RideVariance?> GetVarianceByIdAsync(Guid id);
void UpdateVariance(RideVariance variance);
Task<bool> HasPendingVariancesAsync(Guid rideId);
Task AddCloseSummaryAsync(RideCloseSummary summary);
Task<RideCloseSummary?> GetCloseSummaryByRideAsync(Guid rideId);
/// <summary>Sum of Cash payments on sales belonging to the given ride.</summary>
Task<decimal> GetCashCollectedByRideAsync(Guid rideId);
}
@@ -0,0 +1,73 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Reconciliation;
[ApiController]
[Route("api/rides/{rideId:guid}")]
public class ReconciliationController(ReconciliationService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpPost("cycle-count")]
public async Task<IActionResult> SubmitCycleCount(Guid rideId, SubmitCycleCountRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SubmitCycleCountAsync(rideId, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("variances")]
public async Task<IActionResult> GetVariances(Guid rideId)
{
try
{
var result = await service.GetVariancesAsync(rideId);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("variances/{id:guid}/approve")]
public async Task<IActionResult> ApproveVariance(Guid rideId, Guid id, ResolveVarianceRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ApproveVarianceAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("variances/{id:guid}/reject")]
public async Task<IActionResult> RejectVariance(Guid rideId, Guid id, ResolveVarianceRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RejectVarianceAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("close-ride")]
public async Task<IActionResult> CloseRide(Guid rideId, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CloseRideAsync(rideId, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,19 @@
using Distribution.Models;
namespace Distribution.Features.Reconciliation;
public record CycleCountItemLine(Guid ItemId, decimal PhysicalQuantity);
public record SubmitCycleCountRequest(List<CycleCountItemLine> Items, decimal CashOnHand);
public record RideVarianceResponse(
Guid Id, Guid RideId, Guid? ItemId, string? ItemName,
bool IsCash, decimal ExpectedQuantity, decimal ActualQuantity, decimal VarianceQuantity,
string? ReasonCode, VarianceStatus Status, Guid? ResolvedByUserId, DateTime? ResolvedAt);
public record ResolveVarianceRequest(string ReasonCode);
public record CycleCountResult(bool HasVariances, List<RideVarianceResponse> Variances);
public record RideCloseSummaryResponse(
Guid Id, Guid RideId, decimal ExpectedCash, decimal ActualCash, decimal CashVariance,
Guid ClosedByUserId, DateTime ClosedAt);
@@ -0,0 +1,51 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Reconciliation;
public class ReconciliationRepository(AppDBContext db) : IReconciliationRepository
{
public async Task AddItemCountAsync(RideItemCount count)
=> await db.RideItemCounts.AddAsync(count);
public async Task<List<RideItemCount>> GetItemCountsByRideAsync(Guid rideId)
=> await db.RideItemCounts
.Where(c => c.FK_RideId == rideId)
.ToListAsync();
public async Task AddVarianceAsync(RideVariance variance)
=> await db.RideVariances.AddAsync(variance);
public async Task<List<RideVariance>> GetVariancesByRideAsync(Guid rideId)
=> await db.RideVariances
.Include(v => v.Item)
.Include(v => v.ResolvedBy)
.Where(v => v.FK_RideId == rideId)
.ToListAsync();
public async Task<RideVariance?> GetVarianceByIdAsync(Guid id)
=> await db.RideVariances
.Include(v => v.Item)
.Include(v => v.ResolvedBy)
.FirstOrDefaultAsync(v => v.Id == id);
public void UpdateVariance(RideVariance variance)
=> db.RideVariances.Update(variance);
public async Task<bool> HasPendingVariancesAsync(Guid rideId)
=> await db.RideVariances
.AnyAsync(v => v.FK_RideId == rideId && v.Status == VarianceStatus.Pending);
public async Task AddCloseSummaryAsync(RideCloseSummary summary)
=> await db.RideCloseSummaries.AddAsync(summary);
public async Task<RideCloseSummary?> GetCloseSummaryByRideAsync(Guid rideId)
=> await db.RideCloseSummaries
.FirstOrDefaultAsync(s => s.FK_RideId == rideId);
public async Task<decimal> GetCashCollectedByRideAsync(Guid rideId)
=> await db.Payments
.Where(p => p.Sale.FK_RideId == rideId && p.Method == PaymentMethod.Cash)
.SumAsync(p => p.Amount);
}
@@ -0,0 +1,396 @@
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Reconciliation;
public class ReconciliationService(
IReconciliationRepository reconciliationRepo,
IRideRepository rideRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<CycleCountResult> SubmitCycleCountAsync(
Guid rideId, SubmitCycleCountRequest request, Guid userId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException($"Cannot submit cycle count for a ride in {ride.Status} status");
var existingCounts = await reconciliationRepo.GetItemCountsByRideAsync(rideId);
if (existingCounts.Count > 0)
throw new InvalidOperationException("Cycle count has already been submitted for this ride");
// Build submitted counts lookup
var submittedCounts = request.Items.ToDictionary(i => i.ItemId, i => i.PhysicalQuantity);
// Save all item count records
var itemCounts = request.Items.Select(line => new RideItemCount
{
FK_RideId = rideId,
FK_ItemId = line.ItemId,
PhysicalQuantity = line.PhysicalQuantity
}).ToList();
// Get current vehicle stock balances
var vehicleBalances = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle);
var variances = new List<RideVariance>();
// Compare each stock balance against the physical count
foreach (var balance in vehicleBalances)
{
var physicalQty = submittedCounts.TryGetValue(balance.FK_ItemId, out var qty) ? qty : 0m;
if (balance.Quantity != physicalQty)
{
variances.Add(new RideVariance
{
FK_RideId = rideId,
FK_ItemId = balance.FK_ItemId,
IsCash = false,
ExpectedQuantity = balance.Quantity,
ActualQuantity = physicalQty,
VarianceQuantity = balance.Quantity - physicalQty,
Status = VarianceStatus.Pending
});
}
}
// Cash variance check
var expectedCash = await reconciliationRepo.GetCashCollectedByRideAsync(rideId);
if (expectedCash != request.CashOnHand)
{
variances.Add(new RideVariance
{
FK_RideId = rideId,
FK_ItemId = null,
IsCash = true,
ExpectedQuantity = expectedCash,
ActualQuantity = request.CashOnHand,
VarianceQuantity = expectedCash - request.CashOnHand,
Status = VarianceStatus.Pending
});
}
ride.Status = RideStatus.PendingClose;
await uow.BeginAsync(ct);
try
{
foreach (var count in itemCounts)
await reconciliationRepo.AddItemCountAsync(count);
foreach (var variance in variances)
await reconciliationRepo.AddVarianceAsync(variance);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
var varianceResponses = variances.Select(ToVarianceResponse).ToList();
return new CycleCountResult(varianceResponses.Count > 0, varianceResponses);
}
public async Task<List<RideVarianceResponse>> GetVariancesAsync(Guid rideId)
{
var variances = await reconciliationRepo.GetVariancesByRideAsync(rideId);
return variances.Select(ToVarianceResponse).ToList();
}
public async Task<RideVarianceResponse> ApproveVarianceAsync(
Guid varianceId, ResolveVarianceRequest request, Guid userId, CancellationToken ct = default)
{
var variance = await reconciliationRepo.GetVarianceByIdAsync(varianceId)
?? throw new KeyNotFoundException("Variance not found");
if (variance.Status != VarianceStatus.Pending)
throw new InvalidOperationException($"Variance is not in Pending status");
var ride = await rideRepo.GetByIdAsync(variance.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.PendingClose)
throw new InvalidOperationException($"Ride is not in PendingClose status");
StockMovement? movement = null;
StockBalance? balance = null;
if (!variance.IsCash)
{
// adjustmentQty = -variance.VarianceQuantity
// positive variance = short → debit vehicle (reduce stock)
// negative variance = surplus → credit vehicle (add stock)
var adjustmentQty = -variance.VarianceQuantity;
balance = await stockRepo.GetBalanceAsync(variance.FK_ItemId!.Value, ride.FK_VehicleId);
if (adjustmentQty < 0)
{
// Debit vehicle: FromLocationId=vehicleId, Quantity=abs(adjustmentQty)
var absQty = Math.Abs(adjustmentQty);
movement = new StockMovement
{
FK_ItemId = variance.FK_ItemId!.Value,
FromLocationId = ride.FK_VehicleId,
FromLocationType = LocationType.Vehicle,
ToLocationId = null,
ToLocationType = null,
Quantity = absQty,
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
ReferenceId = variance.Id,
CreatedAt = DateTime.Now,
CreatedBy = userId
};
if (balance is not null)
{
balance.Quantity -= absQty;
balance.UpdatedAt = DateTime.Now;
}
}
else if (adjustmentQty > 0)
{
// Credit vehicle: ToLocationId=vehicleId, Quantity=adjustmentQty
movement = new StockMovement
{
FK_ItemId = variance.FK_ItemId!.Value,
FromLocationId = null,
FromLocationType = null,
ToLocationId = ride.FK_VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = adjustmentQty,
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
ReferenceId = variance.Id,
CreatedAt = DateTime.Now,
CreatedBy = userId
};
if (balance is not null)
{
balance.Quantity += adjustmentQty;
balance.UpdatedAt = DateTime.Now;
}
else
{
balance = new StockBalance
{
FK_ItemId = variance.FK_ItemId!.Value,
LocationId = ride.FK_VehicleId,
LocationType = LocationType.Vehicle,
Quantity = adjustmentQty,
UpdatedAt = DateTime.Now
};
}
}
}
variance.Status = VarianceStatus.Approved;
variance.ReasonCode = request.ReasonCode;
variance.FK_ResolvedByUserId = userId;
variance.ResolvedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
reconciliationRepo.UpdateVariance(variance);
if (movement is not null)
await stockRepo.AddMovementAsync(movement);
if (balance is not null)
stockRepo.UpdateBalance(balance);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToVarianceResponse(variance);
}
public async Task<RideVarianceResponse> RejectVarianceAsync(
Guid varianceId, ResolveVarianceRequest request, Guid userId, CancellationToken ct = default)
{
var variance = await reconciliationRepo.GetVarianceByIdAsync(varianceId)
?? throw new KeyNotFoundException("Variance not found");
if (variance.Status != VarianceStatus.Pending)
throw new InvalidOperationException($"Variance is not in Pending status");
var ride = await rideRepo.GetByIdAsync(variance.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.PendingClose)
throw new InvalidOperationException($"Ride is not in PendingClose status");
StockMovement? movement = null;
StockBalance? balance = null;
if (!variance.IsCash)
{
var adjustmentQty = -variance.VarianceQuantity;
balance = await stockRepo.GetBalanceAsync(variance.FK_ItemId!.Value, ride.FK_VehicleId);
if (adjustmentQty < 0)
{
var absQty = Math.Abs(adjustmentQty);
movement = new StockMovement
{
FK_ItemId = variance.FK_ItemId!.Value,
FromLocationId = ride.FK_VehicleId,
FromLocationType = LocationType.Vehicle,
ToLocationId = null,
ToLocationType = null,
Quantity = absQty,
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
ReferenceId = variance.Id,
CreatedAt = DateTime.Now,
CreatedBy = userId
};
if (balance is not null)
{
balance.Quantity -= absQty;
balance.UpdatedAt = DateTime.Now;
}
}
else if (adjustmentQty > 0)
{
movement = new StockMovement
{
FK_ItemId = variance.FK_ItemId!.Value,
FromLocationId = null,
FromLocationType = null,
ToLocationId = ride.FK_VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = adjustmentQty,
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
ReferenceId = variance.Id,
CreatedAt = DateTime.Now,
CreatedBy = userId
};
if (balance is not null)
{
balance.Quantity += adjustmentQty;
balance.UpdatedAt = DateTime.Now;
}
else
{
balance = new StockBalance
{
FK_ItemId = variance.FK_ItemId!.Value,
LocationId = ride.FK_VehicleId,
LocationType = LocationType.Vehicle,
Quantity = adjustmentQty,
UpdatedAt = DateTime.Now
};
}
}
}
variance.Status = VarianceStatus.Rejected;
variance.ReasonCode = request.ReasonCode;
variance.FK_ResolvedByUserId = userId;
variance.ResolvedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
reconciliationRepo.UpdateVariance(variance);
if (movement is not null)
await stockRepo.AddMovementAsync(movement);
if (balance is not null)
stockRepo.UpdateBalance(balance);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToVarianceResponse(variance);
}
public async Task<RideCloseSummaryResponse> CloseRideAsync(
Guid rideId, Guid userId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.PendingClose)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
if (await reconciliationRepo.HasPendingVariancesAsync(rideId))
throw new InvalidOperationException("All variances must be resolved before the ride can be closed");
var allVariances = await reconciliationRepo.GetVariancesByRideAsync(rideId);
var cashVariance = allVariances.FirstOrDefault(v => v.IsCash);
var expectedCash = cashVariance?.ExpectedQuantity ?? 0m;
var actualCash = cashVariance?.ActualQuantity ?? 0m;
var cashVarianceAmount = cashVariance?.VarianceQuantity ?? 0m;
var summary = new RideCloseSummary
{
FK_RideId = rideId,
ExpectedCash = expectedCash,
ActualCash = actualCash,
CashVariance = cashVarianceAmount,
FK_ClosedByUserId = userId,
ClosedAt = DateTime.Now
};
ride.Status = RideStatus.Closed;
ride.ClosedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
await reconciliationRepo.AddCloseSummaryAsync(summary);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RideCloseSummaryResponse(
summary.Id,
summary.FK_RideId,
summary.ExpectedCash,
summary.ActualCash,
summary.CashVariance,
summary.FK_ClosedByUserId,
summary.ClosedAt);
}
private static RideVarianceResponse ToVarianceResponse(RideVariance v) =>
new(v.Id, v.FK_RideId, v.FK_ItemId, v.Item?.Name,
v.IsCash, v.ExpectedQuantity, v.ActualQuantity, v.VarianceQuantity,
v.ReasonCode, v.Status, v.FK_ResolvedByUserId, v.ResolvedAt);
}
@@ -7,4 +7,26 @@ public interface IRegionRepository
Task AddAsync(Models.Region entity);
void Update(Models.Region entity);
void Remove(Models.Region entity);
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
Task AddTargetAsync(Models.RegionalTarget target);
void UpdateTarget(Models.RegionalTarget target);
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
Task AddBudgetAsync(Models.RegionalBudget budget);
void UpdateBudget(Models.RegionalBudget budget);
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
void UpdateTaxRule(Models.RegionalTaxRule rule);
Task<int?> GetUserRoleLevelAsync(Guid userId);
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Region;
[ApiController]
[Route("api/regions")]
[Authorize]
public class RegionController(RegionService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "regions.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "regions.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "regions.create")]
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "regions.update")]
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,10 +32,51 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "regions.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/targets/{period}")]
public async Task<IActionResult> SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct)
{
var result = await service.SetTargetAsync(id, period, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/budgets/{period}")]
public async Task<IActionResult> SetBudget(Guid id, string period, SetRegionalBudgetRequest request, CancellationToken ct)
{
var result = await service.SetBudgetAsync(id, period, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/agencies")]
public async Task<IActionResult> MapAgency(Guid id, MapAgencyToRegionRequest request, CancellationToken ct)
{
var result = await service.MapAgencyAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/tax-rule")]
public async Task<IActionResult> SetTaxRule(Guid id, SetRegionalTaxRuleRequest request, CancellationToken ct)
{
var result = await service.SetTaxRuleAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/hierarchy")]
public async Task<IActionResult> GetHierarchy(Guid id)
{
var result = await service.GetHierarchyAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/dashboard")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
}
@@ -5,3 +5,32 @@ public record CreateRegionRequest(string Name);
public record UpdateRegionRequest(string Name);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record SetRegionalTargetRequest(decimal TargetValue);
public record SetRegionalBudgetRequest(decimal BudgetAmount);
public record MapAgencyToRegionRequest(Guid AgencyUserId);
public record SetRegionalTaxRuleRequest(string RuleConfig);
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
public record RegionalBudgetResponse(Guid RegionId, string Period, decimal BudgetAmount);
public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
public record RegionDashboardResponse(
Guid RegionId,
string Period,
decimal? TargetValue,
decimal? BudgetAmount,
int OrderCount,
decimal ActualSales,
DateTime? LastOrderDate);
@@ -22,4 +22,60 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
entity.IsActive = false;
context.Regions.Update(entity);
}
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
await context.RegionalTargets.FirstOrDefaultAsync(t =>
t.FK_RegionId == regionId && t.Period == period && t.IsActive);
public async Task AddTargetAsync(Models.RegionalTarget target) =>
await context.RegionalTargets.AddAsync(target);
public void UpdateTarget(Models.RegionalTarget target) =>
context.RegionalTargets.Update(target);
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
b.FK_RegionId == regionId && b.Period == period && b.IsActive);
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
await context.RegionalBudgets.AddAsync(budget);
public void UpdateBudget(Models.RegionalBudget budget) =>
context.RegionalBudgets.Update(budget);
public async Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId) =>
await context.RegionAgencyMaps.FirstOrDefaultAsync(m =>
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId && m.IsActive);
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
await context.RegionAgencyMaps.AddAsync(map);
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId && r.IsActive);
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
await context.RegionalTaxRules.AddAsync(rule);
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
context.RegionalTaxRules.Update(rule);
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
await context.Users
.Where(u => u.Id == userId && u.IsActive)
.Select(u => (int?)u.Role.Level)
.FirstOrDefaultAsync();
public async Task<List<Models.Route>> GetRoutesAsync(Guid regionId) =>
await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync();
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.Orders
join s in context.Stores on o.FK_StoreId equals s.Id
join r in context.Routes on s.FK_RouteId equals r.Id
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
select o).ToListAsync();
}
+169 -1
View File
@@ -1,9 +1,20 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow)
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int RegionalManagerRoleLevel = 50;
private const int RegionalAgencyRoleLevel = 60;
private async Task<bool> CanManageRegionAsync(Guid regionId)
{
if (currentUser.RoleLevel >= RegionalAgencyRoleLevel) return true;
if (currentUser.RoleLevel < RegionalManagerRoleLevel || currentUser.UserId is null) return false;
return await scopeAccess.HasRegionScopeAsync(currentUser.UserId.Value, regionId);
}
public async Task<List<RegionResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
@@ -70,4 +81,161 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow)
}
return true;
}
public async Task<RegionalTargetResponse?> SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var target = await repo.GetTargetAsync(regionId, period);
await uow.BeginAsync(ct);
try
{
if (target is null)
{
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue };
await repo.AddTargetAsync(target);
}
else
{
target.TargetValue = request.TargetValue;
repo.UpdateTarget(target);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionalTargetResponse(regionId, period, target.TargetValue);
}
public async Task<RegionalBudgetResponse?> SetBudgetAsync(Guid regionId, string period, SetRegionalBudgetRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var budget = await repo.GetBudgetAsync(regionId, period);
await uow.BeginAsync(ct);
try
{
if (budget is null)
{
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = period, BudgetAmount = request.BudgetAmount };
await repo.AddBudgetAsync(budget);
}
else
{
budget.BudgetAmount = request.BudgetAmount;
repo.UpdateBudget(budget);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionalBudgetResponse(regionId, period, budget.BudgetAmount);
}
public async Task<RegionAgencyMapResponse?> MapAgencyAsync(Guid regionId, MapAgencyToRegionRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId);
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel) return null;
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId);
if (existing is not null) return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId };
await uow.BeginAsync(ct);
try
{
await repo.AddAgencyMapAsync(map);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
}
public async Task<RegionalTaxRuleResponse?> SetTaxRuleAsync(Guid regionId, SetRegionalTaxRuleRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
if (!await CanManageRegionAsync(regionId)) return null;
var rule = await repo.GetTaxRuleAsync(regionId);
await uow.BeginAsync(ct);
try
{
if (rule is null)
{
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
await repo.AddTaxRuleAsync(rule);
}
else
{
rule.RuleConfig = request.RuleConfig;
repo.UpdateTaxRule(rule);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
}
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);
return new RegionDashboardResponse(
regionId,
normalizedPeriod,
target?.TargetValue,
budget?.BudgetAmount,
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
}
}
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
[ApiController]
[Authorize]
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns/{id:guid}")]
+3 -16
View File
@@ -1,17 +1,14 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
[Authorize]
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetAll(
[FromQuery] RideStatus? status,
[FromQuery] RideType? type,
@@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost]
[Authorize(Policy = "rides.create")]
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -41,7 +36,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPut("{id:guid}/driver")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
{
try
@@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("{id:guid}/open")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
{
try
@@ -66,8 +59,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/activate")]
[HttpPost("{id:guid}/activate")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
{
try
@@ -79,8 +72,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/close")]
[HttpPost("{id:guid}/close")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
{
try
@@ -93,7 +86,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("{id:guid}/cancel-request")]
[Authorize(Policy = "rides.cancel.request")]
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -107,7 +99,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("cancel-requests")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetCancelRequests(
[FromQuery] CancelRequestStatus? status,
[FromQuery] int page = 1,
@@ -118,7 +109,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("cancel-requests/{id:guid}/approve")]
[Authorize(Policy = "rides.cancel.approve")]
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -132,7 +122,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("cancel-requests/{id:guid}/reject")]
[Authorize(Policy = "rides.cancel.approve")]
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -146,7 +135,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("{id:guid}/cancel-request")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetCancelRequest(Guid id)
{
var result = await service.GetCancelRequestAsync(id);
@@ -154,7 +142,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("breakdown-assist/{rideId:guid}")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
{
try
+2 -6
View File
@@ -1,5 +1,4 @@
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -9,7 +8,6 @@ namespace Distribution.Features.Ride;
public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IRideCancelRequestRepository cancelRepo,
Features.Vehicle.IVehicleRepository vehicleRepo,
IStockRepository stockRepo,
@@ -138,11 +136,9 @@ public class RideService(
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
if (ride.Status != RideStatus.PendingClose)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
ride.Status = RideStatus.Closed;
ride.ClosedAt = DateTime.Now;
ride.Meterend = request.Meterend;
@@ -319,7 +315,7 @@ public class RideService(
if (!driver.IsActive)
throw new InvalidOperationException("Driver profile is inactive");
var config = await complianceRepo.GetAsync();
var config = await driverRepo.GetComplianceConfigAsync();
if (config is null) return;
var today = DateTime.Now.Date;
+1 -3
View File
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Role;
[ApiController]
[Route("api/roles")]
[Authorize]
public class RoleController(RoleService service) : ControllerBase
{
[HttpGet]
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Route;
[ApiController]
[Route("api/routes")]
[Authorize]
public class RouteController(RouteService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "routes.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "routes.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "routes.create")]
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,15 +25,20 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "routes.update")]
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/region")]
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
{
var result = await service.AssignToRegionAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "routes.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
+2
View File
@@ -5,3 +5,5 @@ public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPo
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, bool IsActive, DateTime CreatedAt);
public record AssignRouteToRegionRequest(Guid RegionId);
+24 -1
View File
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
namespace Distribution.Features.Route;
public class RouteService(IRouteRepository repo, IUnitOfWork uow)
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow)
{
public async Task<List<RouteResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
@@ -64,6 +64,29 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
}
public async Task<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
var region = await regionRepo.GetByIdAsync(request.RegionId);
if (region is null || !region.IsActive) return null;
route.FK_RegionId = request.RegionId;
await uow.BeginAsync(ct);
try
{
repo.Update(route);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
@@ -5,7 +5,8 @@ namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync();
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
+17 -9
View File
@@ -1,20 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
[Authorize]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(rideId, page, pageSize));
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +21,6 @@ public class SaleController(SaleService service) : ControllerBase
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
@@ -30,10 +28,20 @@ public class SaleController(SaleService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPatch("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+15 -2
View File
@@ -9,8 +9,21 @@ public class SaleRepository(AppDBContext context) : ISaleRepository
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync() =>
await context.Sales.ToListAsync();
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
await context.Sales
.Where(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
await context.Sales
.CountAsync(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
+27 -7
View File
@@ -71,14 +71,14 @@ public class SaleService(
sale.IsActive,
sale.CreatedAt);
public async Task<List<SaleResponse>> GetAllAsync()
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
{
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
IEnumerable<Guid>? storeIds = null;
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
}
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
var total = await repo.GetCountAsync(rideId, storeIds);
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
var result = new List<SaleResponse>();
foreach (var sale in sales)
@@ -88,7 +88,7 @@ public class SaleService(
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
@@ -119,6 +119,26 @@ public class SaleService(
return result;
}
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Sale is already cancelled");
sale.Status = SaleStatus.Cancelled;
await uow.BeginAsync(ct);
try { repo.Update(sale); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Scheme;
[ApiController]
[Route("api/schemes")]
[Authorize]
public class SchemeController(SchemeService service) : ControllerBase
{
[HttpGet]
@@ -120,7 +118,6 @@ public class SchemeController(SchemeService service) : ControllerBase
[ApiController]
[Route("api/orders")]
[Authorize]
public class SchemeOrderController(SchemeService service) : ControllerBase
{
[HttpPost("{id:guid}/apply-scheme")]
@@ -1,16 +1,14 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Stock;
[ApiController]
[Route("api/stock")]
[Authorize]
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
{
// ── Query ──────────────────────────────────────────────────────────────
// ── Query ──────────────────────────────────────────────────────────────
[HttpGet("balances")]
public async Task<IActionResult> GetBalances(
@@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
// ── Write ──────────────────────────────────────────────────────────────
// ── Write ──────────────────────────────────────────────────────────────
[HttpPost("transfer")]
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
@@ -1,15 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store.Kyc;
[ApiController]
[Route("api/stores/{storeId:guid}/kyc-documents")]
[Authorize]
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetByStoreId(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
@@ -17,7 +14,6 @@ public class StoreKycDocumentController(StoreKycDocumentService service) : Contr
}
[HttpPost]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
{
var result = await service.UploadAsync(storeId, request, ct);
+8 -16
View File
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store;
[ApiController]
[Route("api/stores")]
[Authorize]
public class StoreController(StoreService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "stores.create")]
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "stores.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -46,7 +39,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/credit-limit")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
@@ -54,7 +46,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
@@ -62,7 +53,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetOutstandingBalance(Guid id)
{
var result = await service.GetOutstandingBalanceAsync(id);
@@ -70,7 +60,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/kyc/verify")]
[Authorize(Policy = "stores.kyc.verify")]
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, request, ct);
@@ -78,7 +67,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/segment")]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
{
var result = await service.ClassifySegmentAsync(id, request, ct);
@@ -86,7 +74,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/visit-history")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetVisitHistory(Guid id)
{
var result = await service.GetVisitHistoryAsync(id);
@@ -94,7 +81,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
{
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
@@ -102,10 +88,16 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/kyc-status")]
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
return result is null ? NotFound() : Ok(result);
}
}
+2
View File
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record PatchKycStatusRequest(KycStatus KycStatus);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Supplier;
[ApiController]
[Route("api/suppliers")]
[Authorize]
public class SupplierController(SupplierService service) : ControllerBase
{
[HttpGet]
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierClaims;
[ApiController]
[Authorize]
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/supplier-claims/{id:guid}")]
@@ -32,7 +30,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/approve")]
[Authorize(Policy = "suppliers.claims.approve")]
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -46,7 +43,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/reject")]
[Authorize(Policy = "suppliers.claims.approve")]
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
+1 -3
View File
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sync;
[ApiController]
[Route("api/sync")]
[Authorize(Policy = "items.create")]
public class SyncController(SyncService service) : ControllerBase
{
[HttpPost("uoms")]
+1 -8
View File
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Uom;
[ApiController]
[Route("api/uoms")]
[Authorize]
public class UomController(UomService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class UomController(UomService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class UomController(UomService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class UomController(UomService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
+1 -10
View File
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.User;
[ApiController]
[Route("api/users")]
[Authorize]
public class UserController(UserService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "users.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "users.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "users.create")]
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "users.update")]
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPut("{id:guid}/role")]
[Authorize(Policy = "users.assign_role")]
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
{
var result = await service.AssignRoleAsync(id, request, ct);
@@ -46,7 +39,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPut("{id:guid}/scope")]
[Authorize(Policy = "users.assign_scope")]
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
{
var updated = await service.AssignScopeAsync(id, request, ct);
@@ -54,7 +46,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "users.deactivate")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var deactivated = await service.DeactivateAsync(id, ct);
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Vehicle;
[ApiController]
[Route("api/vehicles")]
[Authorize]
public class VehicleController(VehicleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "vehicles.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "vehicles.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class VehicleController(VehicleService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "vehicles.create")]
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class VehicleController(VehicleService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "vehicles.update")]
public async Task<IActionResult> Update(Guid id, UpdateVehicleRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class VehicleController(VehicleService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "vehicles.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Warehouse;
[ApiController]
[Route("api/warehouses")]
[Authorize]
public class WarehouseController(WarehouseService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "warehouses.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "warehouses.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "warehouses.create")]
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "warehouses.update")]
public async Task<IActionResult> Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "warehouses.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Distribution.Infra.Authorization;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticatedAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
if (context.HttpContext.User.Identity?.IsAuthenticated != true)
context.Result = new UnauthorizedResult();
}
}
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Infra.Authorization;
[ApiController]
[Route("api/authorization")]
[Authorize]
public class AuthorizationController(
IPermissionService permissionService,
IScopeAccessService scopeAccessService,
@@ -1,24 +1,23 @@
using Microsoft.AspNetCore.Authorization;
//namespace Distribution.Infra.Authorization;
namespace Distribution.Infra.Authorization;
//public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
//{
// private readonly IPermissionService _permissionService;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
private readonly IPermissionService _permissionService;
// public PermissionAuthorizationHandler(IPermissionService permissionService)
// {
// _permissionService = permissionService;
// }
public PermissionAuthorizationHandler(IPermissionService permissionService)
{
_permissionService = permissionService;
}
// protected override async Task HandleRequirementAsync(
// AuthorizationHandlerContext context, PermissionRequirement requirement)
// {
// var roleIdClaim = context.User.FindFirst("role_id")?.Value;
// if (!Guid.TryParse(roleIdClaim, out var roleId))
// return;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
var roleIdClaim = context.User.FindFirst("role_id")?.Value;
if (!Guid.TryParse(roleIdClaim, out var roleId))
return;
if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
context.Succeed(requirement);
}
}
// if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
// context.Succeed(requirement);
// }
//}
@@ -1,32 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
//using Microsoft.Extensions.Options;
namespace Distribution.Infra.Authorization;
//namespace Distribution.Infra.Authorization;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
//public class PermissionPolicyProvider : IAuthorizationPolicyProvider
//{
// private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
// public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
// {
// _fallback = new DefaultAuthorizationPolicyProvider(options);
// }
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
// public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
// public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.Contains('.'))
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
// public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
// {
// if (policyName.Contains('.'))
// {
// var policy = new AuthorizationPolicyBuilder()
// .RequireAuthenticatedUser()
// .AddRequirements(new PermissionRequirement(policyName))
// .Build();
// return Task.FromResult<AuthorizationPolicy?>(policy);
// }
return _fallback.GetPolicyAsync(policyName);
}
}
// return _fallback.GetPolicyAsync(policyName);
// }
//}
@@ -1,13 +1,12 @@
using Microsoft.AspNetCore.Authorization;
//namespace Distribution.Infra.Authorization;
namespace Distribution.Infra.Authorization;
//public class PermissionRequirement : IAuthorizationRequirement
//{
// public string PermissionCode { get; }
public class PermissionRequirement : IAuthorizationRequirement
{
public string PermissionCode { get; }
public PermissionRequirement(string permissionCode)
{
PermissionCode = permissionCode;
}
}
// public PermissionRequirement(string permissionCode)
// {
// PermissionCode = permissionCode;
// }
//}
+44
View File
@@ -0,0 +1,44 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Middleware;
public class AuthMiddleware(RequestDelegate next, IConfiguration config)
{
public async Task InvokeAsync(HttpContext context)
{
var token = context.Request.Headers.Authorization
.ToString()
.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase)
.Trim();
if (!string.IsNullOrEmpty(token))
{
var key = config["Jwt:Key"];
if (!string.IsNullOrEmpty(key))
{
try
{
var principal = new JwtSecurityTokenHandler().ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
}, out _);
context.User = principal;
}
catch { }
}
}
await next(context);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddRideReconciliation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RideCloseSummaries",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ExpectedCash = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ActualCash = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CashVariance = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
FK_ClosedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RideCloseSummaries", x => x.Id);
table.ForeignKey(
name: "FK_RideCloseSummaries_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RideCloseSummaries_Users_FK_ClosedByUserId",
column: x => x.FK_ClosedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RideItemCounts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
PhysicalQuantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RideItemCounts", x => x.Id);
table.ForeignKey(
name: "FK_RideItemCounts_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RideItemCounts_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RideVariances",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
IsCash = table.Column<bool>(type: "tinyint(1)", nullable: false),
ExpectedQuantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ActualQuantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
VarianceQuantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ReasonCode = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<int>(type: "int", nullable: false),
FK_ResolvedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
ResolvedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RideVariances", x => x.Id);
table.ForeignKey(
name: "FK_RideVariances_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RideVariances_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id");
table.ForeignKey(
name: "FK_RideVariances_Users_FK_ResolvedByUserId",
column: x => x.FK_ResolvedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_RideCloseSummaries_FK_ClosedByUserId",
table: "RideCloseSummaries",
column: "FK_ClosedByUserId");
migrationBuilder.CreateIndex(
name: "IX_RideCloseSummaries_FK_RideId",
table: "RideCloseSummaries",
column: "FK_RideId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RideItemCounts_FK_ItemId",
table: "RideItemCounts",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_RideItemCounts_FK_RideId",
table: "RideItemCounts",
column: "FK_RideId");
migrationBuilder.CreateIndex(
name: "IX_RideVariances_FK_ItemId",
table: "RideVariances",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_RideVariances_FK_ResolvedByUserId",
table: "RideVariances",
column: "FK_ResolvedByUserId");
migrationBuilder.CreateIndex(
name: "IX_RideVariances_FK_RideId",
table: "RideVariances",
column: "FK_RideId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RideCloseSummaries");
migrationBuilder.DropTable(
name: "RideItemCounts");
migrationBuilder.DropTable(
name: "RideVariances");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddRegionManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RegionAgencyMaps",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionAgencyMaps", x => x.Id);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Users_FK_AgencyUserId",
column: x => x.FK_AgencyUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalBudgets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BudgetAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalBudgets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalBudgets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTargets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetValue = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTargets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTargets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTaxRules",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTaxRules", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTaxRules_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_AgencyUserId",
table: "RegionAgencyMaps",
column: "FK_AgencyUserId");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_RegionId_FK_AgencyUserId",
table: "RegionAgencyMaps",
columns: new[] { "FK_RegionId", "FK_AgencyUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalBudgets_FK_RegionId_Period",
table: "RegionalBudgets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTargets_FK_RegionId_Period",
table: "RegionalTargets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTaxRules_FK_RegionId",
table: "RegionalTaxRules",
column: "FK_RegionId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RegionAgencyMaps");
migrationBuilder.DropTable(
name: "RegionalBudgets");
migrationBuilder.DropTable(
name: "RegionalTargets");
migrationBuilder.DropTable(
name: "RegionalTaxRules");
}
}
}
@@ -851,6 +851,121 @@ namespace Distribution.Migrations
b.ToTable("Regions");
});
modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_AgencyUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("FK_AgencyUserId");
b.HasIndex("FK_RegionId", "FK_AgencyUserId")
.IsUnique();
b.ToTable("RegionAgencyMaps");
});
modelBuilder.Entity("Distribution.Models.RegionalBudget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("BudgetAmount")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Period")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("FK_RegionId", "Period")
.IsUnique();
b.ToTable("RegionalBudgets");
});
modelBuilder.Entity("Distribution.Models.RegionalTarget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Period")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<decimal>("TargetValue")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_RegionId", "Period")
.IsUnique();
b.ToTable("RegionalTargets");
});
modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("RuleConfig")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("FK_RegionId")
.IsUnique();
b.ToTable("RegionalTaxRules");
});
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
{
b.Property<Guid>("Id")
@@ -889,6 +1004,129 @@ namespace Distribution.Migrations
b.ToTable("RideCancelRequests");
});
modelBuilder.Entity("Distribution.Models.RideCloseSummary", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("ActualCash")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CashVariance")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("ExpectedCash")
.HasColumnType("decimal(65,30)");
b.Property<Guid>("FK_ClosedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RideId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("FK_ClosedByUserId");
b.HasIndex("FK_RideId")
.IsUnique();
b.ToTable("RideCloseSummaries");
});
modelBuilder.Entity("Distribution.Models.RideItemCount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RideId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<decimal>("PhysicalQuantity")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_RideId");
b.ToTable("RideItemCounts");
});
modelBuilder.Entity("Distribution.Models.RideVariance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("ActualQuantity")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("ExpectedQuantity")
.HasColumnType("decimal(65,30)");
b.Property<Guid?>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ResolvedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RideId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsCash")
.HasColumnType("tinyint(1)");
b.Property<string>("ReasonCode")
.HasColumnType("longtext");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("VarianceQuantity")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_ResolvedByUserId");
b.HasIndex("FK_RideId");
b.ToTable("RideVariances");
});
modelBuilder.Entity("Distribution.Models.Role", b =>
{
b.Property<Guid>("Id")
@@ -2185,6 +2423,58 @@ namespace Distribution.Migrations
b.Navigation("PurchaseReturn");
});
modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b =>
{
b.HasOne("Distribution.Models.User", "AgencyUser")
.WithMany()
.HasForeignKey("FK_AgencyUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AgencyUser");
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RegionalBudget", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RegionalTarget", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
{
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
@@ -2196,6 +2486,68 @@ namespace Distribution.Migrations
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.RideCloseSummary", b =>
{
b.HasOne("Distribution.Models.User", "ClosedByUser")
.WithMany()
.HasForeignKey("FK_ClosedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ClosedByUser");
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.RideItemCount", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.RideVariance", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId");
b.HasOne("Distribution.Models.User", "ResolvedBy")
.WithMany()
.HasForeignKey("FK_ResolvedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("ResolvedBy");
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.RolePermission", b =>
{
b.HasOne("Distribution.Models.Permission", "Permission")
+3 -1
View File
@@ -11,7 +11,9 @@ namespace Distribution.Models
public enum RideType { Delivery, Sale }
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
public enum RideStatus { Draft, Open, Active, PendingClose, CancelRequested, Cancelled, Closed }
public enum VarianceStatus { Pending, Approved, Rejected }
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
+16
View File
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionAgencyMap : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
/// <summary>User holding the RegionalAgency role that is mapped to this region.</summary>
public Guid FK_AgencyUserId { get; set; }
[ForeignKey("FK_AgencyUserId")]
public User AgencyUser { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalBudget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal BudgetAmount { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTarget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal TargetValue { get; set; }
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTaxRule : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string RuleConfig { get; set; }
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RideCloseSummary : Template
{
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public decimal ExpectedCash { get; set; }
public decimal ActualCash { get; set; }
public decimal CashVariance { get; set; }
public Guid FK_ClosedByUserId { get; set; }
[ForeignKey("FK_ClosedByUserId")]
public User ClosedByUser { get; set; }
public DateTime ClosedAt { get; set; }
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RideItemCount : Template
{
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public decimal PhysicalQuantity { get; set; }
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RideVariance : Template
{
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid? FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item? Item { get; set; }
public bool IsCash { get; set; }
public decimal ExpectedQuantity { get; set; }
public decimal ActualQuantity { get; set; }
public decimal VarianceQuantity { get; set; }
public string? ReasonCode { get; set; }
public VarianceStatus Status { get; set; } = VarianceStatus.Pending;
public Guid? FK_ResolvedByUserId { get; set; }
[ForeignKey("FK_ResolvedByUserId")]
public User? ResolvedBy { get; set; }
public DateTime? ResolvedAt { get; set; }
}
}
+23 -67
View File
@@ -1,11 +1,10 @@
using System.Security.Claims;
using Distribution.Data;
using Distribution.Features.Auth;
using Distribution.Middleware;
using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.DeliveryRide;
using Distribution.Features.Payment;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Item;
@@ -30,14 +29,12 @@ using Distribution.Features.Sync;
using Distribution.Features.Uom;
using Distribution.Features.User;
using Distribution.Features.Vehicle;
using Distribution.Features.Reconciliation;
using Distribution.Features.Stock;
using Distribution.Features.Warehouse;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
@@ -65,7 +62,6 @@ builder.Services.AddScoped<IUomRepository, UomRepository>();
builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IStockRepository, StockRepository>();
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
builder.Services.AddScoped<IRideRepository, RideRepository>();
builder.Services.AddScoped<IRideCancelRequestRepository, RideCancelRequestRepository>();
builder.Services.AddScoped<ILoadingSheetRepository, LoadingSheetRepository>();
@@ -81,6 +77,7 @@ builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<IReconciliationRepository, ReconciliationRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -102,7 +99,6 @@ builder.Services.AddScoped<StoreKycDocumentService>();
builder.Services.AddScoped<PriceListService>();
builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<DriverProfileService>();
builder.Services.AddScoped<DriverComplianceConfigService>();
builder.Services.AddScoped<RideService>();
builder.Services.AddScoped<LoadingSheetService>();
builder.Services.AddScoped<SchemeService>();
@@ -114,6 +110,7 @@ builder.Services.AddScoped<ClaimService>();
builder.Services.AddScoped<SupplierClaimService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
builder.Services.AddScoped<ReconciliationService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
@@ -126,67 +123,26 @@ builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
// ── Auth Service connector ───────────────────────────────────────────────────
builder.Services.Configure<AuthServiceOptions>(
builder.Configuration.GetSection(AuthServiceOptions.SectionName));
var jwtSection = builder.Configuration.GetSection("Jwt");
var jwtPublicKeyXml = jwtSection["PublicKey"] ?? throw new InvalidOperationException("Jwt:PublicKey is not configured.");
var jwtSigningKey = new RsaSecurityKey(RsaXmlKeyReader.ReadPublicKey(jwtPublicKeyXml));
var authServiceBaseUrl = builder.Configuration[$"{AuthServiceOptions.SectionName}:BaseUrl"];
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtSection["Issuer"],
ValidateAudience = true,
ValidAudience = jwtSection["Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = jwtSigningKey,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
builder.Services.AddHttpClient<AuthServiceClient>(client =>
{
if (!string.IsNullOrWhiteSpace(authServiceBaseUrl))
client.BaseAddress = new Uri(authServiceBaseUrl);
});
// AuthHex issues the token and owns its claim shape; this app's authorization (permission
// policies, role-level checks) runs against the *local* Role/Permission tables, so every
// validated token is mapped back to a local User/Role once here, not duplicated per
// endpoint. A token for a user with no matching local User record is rejected outright —
// login via AuthHex does not implicitly provision a local user.
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var subClaim = context.Principal?.FindFirst("sub")?.Value
?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
builder.Services.AddScoped<IAuthServiceClient, AuthServiceClient>();
if (!Guid.TryParse(subClaim, out var userId))
{
context.Fail("Token is missing a valid user id claim.");
return;
}
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
var user = await userRepo.GetByIdAsync(userId);
if (user is null || !user.IsActive)
{
context.Fail("User not found or inactive.");
return;
}
var identity = (ClaimsIdentity)context.Principal!.Identity!;
identity.AddClaim(new Claim("username", user.Username));
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
}
};
});
builder.Services.AddAuthorization();
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -202,9 +158,9 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
//app.UseHttpsRedirection();
app.UseCors();
app.UseMiddleware<AuthMiddleware>();
app.MapControllers();
app.Run();
+7
View File
@@ -1,4 +1,5 @@
{
"Urls": "http://localhost:5089",
"ConnectionStrings": {
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
},
@@ -9,6 +10,9 @@
}
},
"AllowedHosts": "*",
"Cors": {
"AllowedOrigins": [ "http://localhost:3000" ]
},
"Jwt": {
"PublicKey": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
"Audience": "AuthHexClient",
@@ -21,5 +25,8 @@
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
},
"AuthService": {
"BaseUrl": ""
}
}
+17 -4
View File
@@ -64,16 +64,29 @@ When `FulfillImmediately = false`, no stock movement — order stays Pending for
**Feature folder:** `Features/Payment/`
### API Endpoints
### Sales API Endpoints
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
| GET | `/api/sales` | Any | List sales — supports `?rideId=`, `?page=`, `?pageSize=` query params |
| POST | `/api/sales/{saleId:guid}/payments` | Driver/Agent | Record a payment against a sale |
| GET | `/api/sales/{saleId:guid}/payments` | Any | List all payments for a sale |
**`GET /api/sales` filter params:**
| Param | Type | Description |
|---|---|---|
| `rideId` | `Guid?` | Filter to sales created on a specific ride |
| `page` | `int` | Page number (default 1) |
| `pageSize` | `int` | Items per page (default 20) |
Returns `PagedResponse<SaleResponse>`: `{ page, pageSize, total, items }`.
Scope rules still apply: non-global users only see sales for stores in their scope.
### Payment Creation Logic
On `POST /api/sales/{id}/payments`:
On `POST /api/sales/{saleId:guid}/payments`:
1. Validate sale exists and is not `Cancelled`.
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
+48 -19
View File
@@ -1,6 +1,6 @@
# 10 — Region Management (Beyond CRUD)
**Status**: Not Started
**Status**: Implemented (except `assignAreaToRegion`, blocked on the `Area` entity)
**Depends On**: 00 — Existing Gap Fixes
---
@@ -24,48 +24,77 @@ CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
### Regional Target / Budget
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
Implemented as two separate entities, `RegionalTarget` and `RegionalBudget`
(`Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`), each with `FK_RegionId`, `Period`
(string, `yyyy-MM`), and `TargetValue`/`BudgetAmount` respectively. A unique index on
`(FK_RegionId, Period)` means each region has at most one target and one budget row per period;
setting the same period again updates it in place rather than inserting a duplicate.
### Agency-Region Map
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
`Role` (see Open Questions).
Implemented as `RegionAgencyMap` (`Models/RegionAgencyMap.cs`): `FK_RegionId`, `FK_AgencyUserId`,
unique on the pair. "Regional Agency" stayed a `Role`, not a distinct entity (see Open Questions,
resolved) — `FK_AgencyUserId` points at an existing `User` who holds the `RegionalAgency` role,
the same convention already used by `Claim.FK_AgencyUserId` (§18).
### Regional Tax Rule
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
Implemented as `RegionalTaxRule` (`Models/RegionalTaxRule.cs`): `FK_RegionId` (unique — one rule
per region), `RuleConfig` (raw JSON string). Nothing in pricing/tax calculation reads this yet;
it's stored for a future pricing-rule integration.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
| Function reference | Endpoint | Status |
|---|---|---|
| `assignAreaToRegion(areaId, regionId)` | — | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). Stand-in: `PUT /api/routes/{id}/region` assigns/reassigns an existing route directly to a region (see [11_route_sales_management.md](./11_route_sales_management.md)). |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` | Implemented |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` | Implemented |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` | Implemented |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` | Implemented |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` | Implemented (region → routes → store ids; no `Area` level) |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` | Implemented (real-time aggregation; see Open Questions, resolved) |
All request/response DTOs live in `Features/Region/RegionDTOs.cs`; routing and access checks are
in `RegionController.cs` and `RegionService.cs`.
---
## Business Rules
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
regions. Implemented via `RegionService.CanManageRegionAsync`: role level ≥ 60 (`RegionalAgency`)
is allowed unconditionally, role level ≥ 50 (`RegionalManager`) requires a matching
`UserRegionScope` row for that region, anything lower is rejected. The same gate is applied to
`mapAgencyToRegion`.
- `mapAgencyToRegion` additionally rejects the target user if their role level is below the
`RegionalAgency` threshold (60) — you can only map a user who actually holds (or outranks) that
role.
---
## Open Questions
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
third party with its own record), or is the role sufficient?
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
- ~~Is "Regional Agency" a business entity distinct from the `RegionalAgency` role...?~~ Resolved:
kept as a `Role` on `User`; no new agency entity, consistent with `Claim.FK_AgencyUserId`.
- ~~Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?~~ Resolved:
real-time aggregation from `Order`/`Store`/`Route` for now; revisit if/when region-wide dashboard
queries become a performance concern at scale.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- Backend: `Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`, `Models/RegionAgencyMap.cs`,
`Models/RegionalTaxRule.cs`, `Data/AppDBContext.cs` (DbSets + unique indexes),
`Features/Region/{IRegionRepository,RegionRepository,RegionService,RegionController,RegionDTOs}.cs`.
- Frontend: `distribution_fe/types/api.ts` (response types), `distribution_fe/lib/api/master.ts`
(`regions.setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard`), and a new
`RegionManageSheet` dialog (`distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx`)
wired into the regions table's row actions.
- The EF Core migration `20260831050805_AddRegionManagement` has been generated and applied to the
shared database — `RegionalTargets`, `RegionalBudgets`, `RegionAgencyMaps`, and `RegionalTaxRules`
all exist.
+4 -4
View File
@@ -100,8 +100,8 @@ One migration needed:
### New
| Method | Route | Permission | Notes |
|---|---|---|---|
| POST | /api/stores/{id}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
| GET | /api/stores/{id}/kyc-documents | stores.view | Lists documents for a store |
| POST | /api/stores/{storeId:guid}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
| GET | /api/stores/{storeId:guid}/kyc-documents | stores.view | Lists documents for a store |
| PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` |
| PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above |
| PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit |
@@ -115,7 +115,7 @@ One migration needed:
## Request / Response Shapes
### POST `/api/stores/{id}/kyc-documents` (multipart/form-data)
### POST `/api/stores/{storeId:guid}/kyc-documents` (multipart/form-data)
Fields: `documentType` (string), `file` (binary).
Response:
@@ -247,7 +247,7 @@ ever belong to a Store and have no independent lifecycle.
### KYC upload logic (pseudo)
```
POST /api/stores/{id}/kyc-documents — multipart (documentType, file):
POST /api/stores/{storeId:guid}/kyc-documents — multipart (documentType, file):
1. Load Store by id, reject if missing/inactive or caller lacks store scope
2. Ensure wwwroot/uploads/kyc/{storeId}/ exists
3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}"
+1 -1
View File
@@ -347,7 +347,7 @@ GET /api/schemes/eligible?storeId=...&orderId=...
**Apply it:**
```
POST /api/orders/{orderId}/apply-scheme
POST /api/orders/{id}/apply-scheme
{ "schemeId": "..." }
```
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
+1 -1
View File
@@ -46,7 +46,7 @@ home until this step. There is no invoice, payment, or GL entity for warehouse-f
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation |
| `recordPayment(invoiceId, amount, method)` | `POST /api/invoices/{id}/payments` |
| `recordPayment(invoiceId, amount, method)` | Current implemented path is `POST /api/sales/{saleId:guid}/payments` for field-sale collections; `POST /api/invoices/{id}/payments` remains the target endpoint for this future invoice-based finance step |
| `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events |
| `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` |
| `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` |
+13 -13
View File
@@ -54,7 +54,7 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
| 10 | Region Management (Beyond CRUD) | Done | 00 | [10_region_management.md](./10_region_management.md) |
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
@@ -116,19 +116,19 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. |
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. |
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). As a stand-in, a route (the next tier down) can be assigned/reassigned to a region directly via `PUT /api/routes/{id}/region`. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/targets/{period}`, backed by the `RegionalTarget` entity. Restricted to `RegionalManager`+ with region scope, or `RegionalAgency` across any region. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/budgets/{period}`, backed by the `RegionalBudget` entity. Same access rule as `setRegionalTarget`. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `POST /api/regions/{id}/agencies`, backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/tax-rule`, backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). |
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Partially implemented — `GET /api/regions/{id}/hierarchy` returns the region's routes and each route's store ids; no `Area` level since that entity doesn't exist yet. |
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Partially implemented — `GET /api/regions/{id}/dashboard?period=...` returns real-time order count, actual sales, target, and budget for the period; no fill-rate or agency-performance metrics yet. |
### 3. Route Management
| Function | What it does | Status |
|---|---|---|
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). |
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. |
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. |
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. |
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. |
@@ -143,9 +143,9 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{id}/kyc-documents`. |
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{id}/kyc-documents`. |
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{storeId:guid}/kyc-documents` endpoints rather than inline on create. |
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{storeId:guid}/kyc-documents`. |
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{storeId:guid}/kyc-documents`. |
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
| `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. |
@@ -281,7 +281,7 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId:guid}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. |
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
@@ -0,0 +1,277 @@
---
name: shadcn
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI, including chat interfaces. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
# shadcn/ui
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
## Current Project Context
```json
!`npx shadcn@latest info --json`
```
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
## Principles
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`className` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast follows the project base.** Use `toast` from the `toast` component for
Base UI projects. Use `toast()` from `sonner` for Radix and React Aria
projects.
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
### Chat & Messaging → [chat.md](./rules/chat.md)
- **Chat UI composes the chat primitives.** Conversations use `MessageScroller`, rows use `Message`, surfaces use `Bubble`. Never hand-rolled bubble `div`s or a raw scroll container.
- **`MessageScroller` owns scroll behavior.** Streaming follow, anchoring, and jump-to-latest (`MessageScrollerButton`) are built in. Don't write a `useStickToBottom`/`ResizeObserver` hook.
- **Attachments use `Attachment`; system notes and dividers use `Marker`.** Not `Item` cards or `Separator` + a label.
### CLI
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
## Key Patterns
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
```tsx
// Form layout: FieldGroup + Field, not div + Label.
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" />
</Field>
</FieldGroup>
// Validation: data-invalid on Field, aria-invalid on the control.
<Field data-invalid>
<FieldLabel>Email</FieldLabel>
<Input aria-invalid />
<FieldDescription>Invalid email.</FieldDescription>
</Field>
// Icons in buttons: data-icon, no sizing classes.
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
// Spacing: gap-*, not space-y-*.
<div className="flex flex-col gap-4"> // correct
<div className="space-y-4"> // wrong
// Equal dimensions: size-*, not w-* h-*.
<Avatar className="size-10"> // correct
<Avatar className="w-10 h-10"> // wrong
// Status colors: Badge variants or semantic tokens, not raw colors.
<Badge variant="secondary">+20.1%</Badge> // correct
<span className="text-emerald-600">+20.1%</span> // wrong
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `toast` (Base UI), `sonner` (Radix/Aria), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (wraps Recharts) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
| Chat / conversation UI | `MessageScroller`, `Message`, `Bubble`, `Attachment`, `Marker` |
## Key Fields
The injected project context contains these key fields:
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
See [cli.md — `info` command](./cli.md) for the full field reference.
## Component Docs, Examples, and Usage
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
```bash
npx shadcn@latest docs button dialog select
```
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
## Workflow
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
3. **Find components** — `npx shadcn@latest search`.
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
## Updating Components
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
3. Decide per file based on the diff:
- No local changes → safe to overwrite.
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
- User says "just update everything" → use `--overwrite`, but confirm first.
4. **Never use `--overwrite` without the user's explicit approval.**
## Quick Reference
```bash
# Create a new project.
npx shadcn@latest init --name my-app --preset base-nova
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
# Create a monorepo project.
npx shadcn@latest init --name my-app --preset base-nova --monorepo
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
# Apply a preset to an existing project.
npx shadcn@latest apply a2r6bw
npx shadcn@latest apply a2r6bw --only theme
npx shadcn@latest apply a2r6bw --only font
npx shadcn@latest apply a2r6bw --only theme,font
# Inspect preset codes and project preset state.
npx shadcn@latest preset decode a2r6bw
npx shadcn@latest preset url a2r6bw
npx shadcn@latest preset open a2r6bw
npx shadcn@latest preset resolve
npx shadcn@latest preset resolve --json
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
npx shadcn@latest add owner/repo/item
npx shadcn@latest add --all
# Preview changes before adding/updating.
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff button.tsx
npx shadcn@latest add @acme/form --view button.tsx
npx shadcn@latest add owner/repo/item --dry-run
# Search registries.
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
npx shadcn@latest search owner/repo -q "login"
npx shadcn@latest search # all configured registries
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select
# View registry item details (for items not yet installed).
npx shadcn@latest view @shadcn/button
npx shadcn@latest view owner/repo/item
```
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/chat.md](./rules/chat.md) — MessageScroller, Message, Bubble, Attachment, Marker; streaming, anchoring, jump-to-latest
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
- [cli.md](./cli.md) — Commands, flags, presets, templates
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
- [customization.md](./customization.md) — Theming, CSS variables, extending components
@@ -0,0 +1,5 @@
interface:
display_name: "shadcn/ui"
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"

Some files were not shown because too many files have changed in this diff Show More