Compare commits

..

40 Commits

Author SHA1 Message Date
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
DeepnaPooja 08ad17f15b Document auth flow 2026-08-25 16:46:12 +05:30
DeepnaPooja 97a1db1343 Refactor auth to AuthHex 2026-08-25 16:46:05 +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
DeepnaPooja 916a2b8f0a Merge pull request 'Feat/returns & claims' (#11) from feat/returns-&-claims into dev
Reviewed-on: #11
2026-08-25 10:14:38 +00:00
DeepnaPooja 3caacf44d0 update md files 2026-08-25 15:38:22 +05:30
DeepnaPooja fe9ccb4de7 Update procurement docs 2026-08-25 14:48:02 +05:30
DeepnaPooja f244a04dc0 Add returns and claims 2026-08-25 14:47:51 +05:30
DeepnaPooja aa543d0d0d Merge pull request 'Production' (#10) from production into dev
Reviewed-on: #10
2026-08-25 05:42:16 +00:00
Kalana B. Thilakarathna 87146e4662 Merge branch 'dev' into production 2026-08-24 21:42:41 +05:30
Kalana B. Thilakarathna cefefcbc49 md update 2026-08-24 21:42:13 +05:30
Kalana B. Thilakarathna fcca03d0b2 Merge branch 'dev' into production 2026-08-24 21:38:13 +05:30
Kalana B. Thilakarathna 6d8b6b3e59 Add DeliveryTask, Payment, and Sale features
Implemented DeliveryTask, Payment, and Sale entities with full CRUD, business logic, and API endpoints. Updated Order/OrderItem for sale linkage and fulfillment tracking. Registered new DbSets and DI services. Revised documentation for delivery, sale, and payment workflows.
2026-08-24 21:34:57 +05:30
DeepnaPooja 3837facb02 Merge pull request 'Dev' (#9) from dev into production
Reviewed-on: #9
2026-08-24 11:29:34 +00:00
DeepnaPooja a833586e83 Merge pull request 'Feat/procurement' (#8) from feat/procurement into dev
Reviewed-on: #8
2026-08-24 11:28:48 +00:00
Kalana B. Thilakarathna ade41b4a60 Merge branch 'dev' into production 2026-08-24 11:24:06 +05:30
DeepnaPooja 3680fc9035 Merge pull request 'Dev' (#6) from dev into production
Reviewed-on: #6
2026-08-21 08:52:16 +00:00
Kalana B. Thilakarathna f6211a8edd Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-08-21 02:08:50 +05:30
DeepnaPooja 83bf715716 Merge pull request 'Dev' (#4) from dev into production
Reviewed-on: #4
2026-08-20 08:14:20 +00:00
376 changed files with 50403 additions and 1065 deletions
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
@@ -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") =>
+161
View File
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
@@ -41,12 +42,24 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
public DbSet<LoadingSheet> LoadingSheets { get; set; }
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
public DbSet<Payment> Payments { get; set; }
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; }
public DbSet<SaleReturnItem> SaleReturnItems { get; set; }
public DbSet<CreditNote> CreditNotes { get; set; }
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; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -79,6 +92,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
@@ -102,6 +119,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
@@ -238,6 +280,125 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.CreatedByUser)
.WithMany()
.HasForeignKey(r => r.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasOne(i => i.SaleReturn)
.WithMany(r => r.Items)
.HasForeignKey(i => i.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId);
modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn)
.WithMany()
.HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser)
.WithMany()
.HasForeignKey(c => c.FK_AgencyUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Claim>()
.HasOne(c => c.SettledByUser)
.WithMany()
.HasForeignKey(c => c.SettledByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.PurchaseOrder)
.WithMany()
.HasForeignKey(c => c.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.GoodsReceipt)
.WithMany()
.HasForeignKey(c => c.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.RaisedByUser)
.WithMany()
.HasForeignKey(c => c.RaisedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.ResolvedByUser)
.WithMany()
.HasForeignKey(c => c.ResolvedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasOne(i => i.SupplierClaim)
.WithMany(c => c.Items)
.HasForeignKey(i => i.FK_SupplierClaimId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
.HasForeignKey(t => t.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Order)
.WithMany()
.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();
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
+26 -4
View File
@@ -1,15 +1,37 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Auth;
[ApiController]
[Route("api/auth")]
public class AuthController(AuthService service) : ControllerBase
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
{
private static IActionResult ToActionResult(AuthHexResponse result) =>
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request)
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
ToActionResult(await service.LoginAsync(request, ct));
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
ToActionResult(await service.RegisterAsync(request, ct));
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken ct)
{
var result = await service.LoginAsync(request);
return result is null ? Unauthorized() : Ok(result);
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
{
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
return ToActionResult(await service.LogoutAsync(token, ct));
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ namespace Distribution.Features.Auth;
public record LoginRequest(string Username, string Password);
public record LoginResponse(string Token, Guid UserId, string Username, string RoleName);
public record RegisterRequest(string Username, string Password, string? FullName);
+15 -15
View File
@@ -1,24 +1,24 @@
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Identity;
namespace Distribution.Features.Auth;
public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService)
/// <summary>
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
/// </summary>
public class AuthService(IAuthHexClient authHex)
{
private readonly PasswordHasher<Models.User> _hasher = new();
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
public async Task<LoginResponse?> LoginAsync(LoginRequest request)
{
var user = await userRepo.GetByUsernameAsync(request.Username);
if (user is null || !user.IsActive)
return null;
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (result == PasswordVerificationResult.Failed)
return null;
public Task<AuthHexResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
var token = tokenService.GenerateToken(user);
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
}
public Task<AuthHexResponse> LogoutAsync(string token, CancellationToken ct = default) =>
authHex.CallAsync("LogoutUser", new { token }, ct: ct);
}
@@ -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);
@@ -0,0 +1,39 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
[ApiController]
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("api/claims")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/claims/{id:guid}/settle")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SettleAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+17
View File
@@ -0,0 +1,17 @@
using Distribution.Models;
namespace Distribution.Features.Claims;
public record CreateClaimRequest(Guid AgencyUserId, string Details);
public record SettleClaimRequest(decimal SettlementAmount);
public record ClaimResponse(
Guid Id,
Guid AgencyUserId,
string Details,
ClaimStatus Status,
decimal? SettlementAmount,
DateTime? SettledAt,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,22 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Claims;
public class ClaimRepository(AppDBContext context) : IClaimRepository
{
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
await context.Claims
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.Claim entity) =>
await context.Claims.AddAsync(entity);
public void Update(Models.Claim entity) =>
context.Claims.Update(entity);
}
@@ -0,0 +1,68 @@
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Claims;
public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
{
private static ClaimResponse Map(Claim c) =>
new(c.Id, c.FK_AgencyUserId, c.Details, c.Status, c.SettlementAmount, c.SettledAt, c.IsActive, c.CreatedAt);
public async Task<ClaimResponse?> GetByIdAsync(Guid id)
{
var c = await repo.GetByIdAsync(id);
if (c is null || !c.IsActive) return null;
return Map(c);
}
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
public async Task<ClaimResponse> CreateAsync(CreateClaimRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.Details))
throw new InvalidOperationException("Claim details are required.");
var claim = new Claim
{
FK_AgencyUserId = request.AgencyUserId,
Details = request.Details
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id);
if (claim is null || !claim.IsActive)
throw new KeyNotFoundException("Claim not found.");
if (claim.Status == ClaimStatus.Settled)
throw new InvalidOperationException("Claim is already settled.");
if (request.SettlementAmount < 0)
throw new InvalidOperationException("Settlement amount cannot be negative.");
claim.Status = ClaimStatus.Settled;
claim.SettlementAmount = request.SettlementAmount;
claim.SettledByUserId = approverId;
claim.SettledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
}
@@ -0,0 +1,9 @@
namespace Distribution.Features.Claims;
public interface IClaimRepository
{
Task<Models.Claim?> GetByIdAsync(Guid id);
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
Task AddAsync(Models.Claim entity);
void Update(Models.Claim entity);
}
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
{
var result = await service.GenerateAsync(rideId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
{
var result = await service.SetQuantitiesAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
{
var result = await service.FailAsync(id, request.Note, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,24 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
DeliveryTaskStatus Status,
bool CustomerConfirmed,
DateTime? ConfirmedAt,
string? Note,
List<DeliveryTaskItemResponse> Items,
DateTime CreatedAt);
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
public record FailTaskRequest(string Note);
@@ -0,0 +1,38 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
public void Update(DeliveryTask task) =>
context.DeliveryTasks.Update(task);
}
@@ -0,0 +1,224 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
i.QuantityFulfilled,
i.UnitPrice)).ToList(),
t.CreatedAt);
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
var tasks = await repo.GetByRideIdAsync(rideId);
return tasks.Select(ToResponse).ToList();
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
return task is null ? null : ToResponse(task);
}
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Quantities can only be set for a Pending task");
foreach (var input in request.Items)
{
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
if (vehicleBalance.Quantity < item.QuantityFulfilled)
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
vehicleBalance.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be failed");
task.Status = DeliveryTaskStatus.Failed;
task.Note = note;
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(task);
}
}
@@ -0,0 +1,12 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
}
@@ -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,15 +1,17 @@
using Microsoft.AspNetCore.Authorization;
using Distribution.Features.DriverComplianceConfig;
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,
DriverComplianceConfigService complianceService,
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 +19,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 +26,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 +51,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 complianceService.GetAsync();
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 complianceService.UpsertAsync(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);
@@ -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
+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);
@@ -39,9 +39,12 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive && oi.Order.IsActive && oi.Order.Store.FK_RouteId == routeId)
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) })
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
@@ -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))
@@ -4,10 +4,12 @@ public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
+13 -13
View File
@@ -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,23 +25,28 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "orders.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
+8 -5
View File
@@ -1,18 +1,21 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateOrderRequest(Guid SaleId);
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
public record UpdateOrderRequest(OrderStatus Status);
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
}
+38 -87
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Sale;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
@@ -5,7 +6,6 @@ using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
@@ -15,16 +15,16 @@ public class OrderService(
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -35,42 +35,6 @@ public class OrderService(
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
/// <summary>
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
/// can be resolved, or if the requested price is outside the allowed band.
/// </summary>
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
/// <summary>
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
/// </summary>
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
{
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
var totalAvailable = balances.Sum(b => b.Quantity);
return quantity <= totalAvailable;
}
public async Task<List<OrderResponse>> GetAllAsync()
{
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
@@ -112,52 +76,15 @@ public class OrderService(
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var order = new Models.Order
{
FK_StoreId = request.StoreId,
OrderDate = request.OrderDate,
TotalAmount = request.TotalAmount
};
var orderItems = new List<Models.OrderItem>();
foreach (var i in request.Items)
{
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity,
UnitPrice = unitPrice.Value
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(order);
await repo.AddItemsAsync(orderItems);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, orderItems);
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
@@ -165,12 +92,26 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
order.OrderDate = request.OrderDate;
order.TotalAmount = request.TotalAmount;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
@@ -178,6 +119,7 @@ public class OrderService(
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
@@ -187,10 +129,19 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch
@@ -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 }); }
}
}
@@ -0,0 +1,12 @@
using Distribution.Models;
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);
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetBySale(Guid saleId)
{
var result = await service.GetBySaleIdAsync(saleId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(saleId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,17 @@
using Distribution.Models;
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,
Guid StoreId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
Guid CollectedBy,
DateTime CreatedAt);
@@ -0,0 +1,37 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
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)
.OrderBy(p => p.CollectedAt)
.ToListAsync();
public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment);
}
@@ -0,0 +1,81 @@
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
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);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var payment = new Models.Payment
{
FK_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
CollectedBy = userId
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(payment);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
sale.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < sale.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(payment);
}
}
@@ -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}")]
@@ -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));
}
}
@@ -0,0 +1,12 @@
namespace Distribution.Features.Returns;
public interface IReturnRepository
{
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.SaleReturn entity);
Task AddCreditNoteAsync(Models.CreditNote entity);
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
}
@@ -0,0 +1,31 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
[ApiController]
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/orders/{id:guid}/returns")]
public async Task<IActionResult> GetByOrder(Guid id) =>
Ok(await service.GetByOrderIdAsync(id));
[HttpPost("api/orders/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,26 @@
using Distribution.Models;
namespace Distribution.Features.Returns;
public record ReturnItemRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
public record CreateSaleReturnRequest(ReturnMode Mode, List<ReturnItemRequest> Items);
public record SaleReturnItemResponse(
Guid Id,
Guid ItemId,
string Sku,
string ItemName,
decimal Quantity,
ReturnReasonCode ReasonCode,
decimal UnitPrice);
public record SaleReturnResponse(
Guid Id,
Guid OrderId,
ReturnMode Mode,
decimal TotalCreditAmount,
decimal OrderTotalAmountAfter,
bool IsActive,
DateTime CreatedAt,
List<SaleReturnItemResponse> Items);
@@ -0,0 +1,32 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Returns;
public class ReturnRepository(AppDBContext context) : IReturnRepository
{
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
await context.SaleReturns
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
await context.SaleReturns
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.FK_OrderId == orderId && r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.SaleReturn entity) =>
await context.SaleReturns.AddAsync(entity);
public async Task AddCreditNoteAsync(Models.CreditNote entity) =>
await context.CreditNotes.AddAsync(entity);
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
await context.OrderItems
.Include(oi => oi.Order)
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
}
@@ -0,0 +1,169 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Returns;
public class ReturnService(
IReturnRepository repo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IRideRepository rideRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
private static SaleReturnResponse Map(SaleReturn r) =>
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
{
var r = await repo.GetByIdAsync(id);
if (r is null || !r.IsActive) return null;
return Map(r);
}
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
/// <summary>
/// Records a return recognized during the same store visit as an open order and settles it
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
/// A return is only allowed for an item the store has previously ordered.
/// </summary>
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
throw new InvalidOperationException("Cannot record a return against a closed order.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order.");
if (sale.FK_RideId is null)
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
?? throw new InvalidOperationException("Ride not found.");
var vehicleId = ride.FK_VehicleId;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
var saleReturn = new SaleReturn
{
FK_OrderId = orderId,
Mode = request.Mode,
CreatedByUserId = createdByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var item = await itemRepo.GetByIdAsync(line.ItemId)
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
? matchingOrderItem.UnitPrice
: item.SalePrice ?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice
});
}
await uow.BeginAsync(ct);
try
{
foreach (var line in saleReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = line.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = line.Quantity
});
}
else
{
balance.Quantity += line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = line.Quantity,
Type = MovementType.CustomerReturn,
ReasonCode = line.ReasonCode.ToString(),
ReferenceId = saleReturn.Id,
CreatedBy = createdByUserId
});
}
await repo.AddAsync(saleReturn);
if (request.Mode == ReturnMode.CreditDeduction)
{
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = total;
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
orderRepo.Update(order);
await repo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = total
});
}
else // ItemExchange
{
foreach (var line in saleReturn.Items)
{
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
continue; // no matching line on this order — nothing to offset
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
orderItem.Quantity -= (int)offsetQty;
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
}
orderRepo.Update(order);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(saleReturn.Id);
return Map(saved!);
}
}
+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
+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);
@@ -0,0 +1,15 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
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);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
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}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? Forbid() : Ok(result);
}
[HttpPost]
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 }); }
}
}
+33
View File
@@ -0,0 +1,33 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,45 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
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(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();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
+253
View File
@@ -0,0 +1,253 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
{
IEnumerable<Guid>? storeIds = null;
if (!HasGlobalScope && currentUser.UserId is not null)
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)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
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<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
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;
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
TotalAmount = request.TotalAmount,
Status = saleStatus,
FulfillImmediately = request.FulfillImmediately,
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
UnitPrice = unitPrice.Value
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
TotalAmount = request.TotalAmount,
Status = orderStatus,
FulfilledAt = fulfilledAt
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
if (ride is null) return null;
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
if (balance is null || balance.Quantity < item.Quantity)
return null;
}
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
Quantity = item.Quantity,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
@@ -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]
@@ -0,0 +1,9 @@
namespace Distribution.Features.SupplierClaims;
public interface ISupplierClaimRepository
{
Task<Models.SupplierClaim?> GetByIdAsync(Guid id);
Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.SupplierClaim entity);
void Update(Models.SupplierClaim entity);
}
@@ -0,0 +1,57 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierClaims;
[ApiController]
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/supplier-claims/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/purchase-orders/{id:guid}/claims")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/claims")]
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/supplier-claims/{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ApproveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/supplier-claims/{id:guid}/reject")]
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RejectAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,41 @@
using Distribution.Models;
namespace Distribution.Features.SupplierClaims;
public record SupplierClaimItemRequest(Guid ItemId, decimal Quantity, SupplierClaimIssueType IssueType);
public record CreateSupplierClaimRequest(
Guid? GoodsReceiptId,
string Details,
string? EvidenceUrl,
List<SupplierClaimItemRequest> Items);
public record ApproveSupplierClaimRequest(
SupplierClaimResolutionType ResolutionType,
decimal? ResolutionAmount,
string? ResolutionNotes);
public record RejectSupplierClaimRequest(string? ResolutionNotes);
public record SupplierClaimItemResponse(
Guid Id,
Guid ItemId,
string Sku,
string ItemName,
decimal Quantity,
SupplierClaimIssueType IssueType);
public record SupplierClaimResponse(
Guid Id,
Guid PurchaseOrderId,
Guid? GoodsReceiptId,
string Details,
string? EvidenceUrl,
SupplierClaimStatus Status,
SupplierClaimResolutionType? ResolutionType,
decimal? ResolutionAmount,
string? ResolutionNotes,
DateTime? ResolvedAt,
bool IsActive,
DateTime CreatedAt,
List<SupplierClaimItemResponse> Items);
@@ -0,0 +1,25 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SupplierClaims;
public class SupplierClaimRepository(AppDBContext context) : ISupplierClaimRepository
{
public async Task<Models.SupplierClaim?> GetByIdAsync(Guid id) =>
await context.SupplierClaims
.Include(c => c.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.SupplierClaims
.Include(c => c.Items).ThenInclude(i => i.Item)
.Where(c => c.FK_PurchaseOrderId == purchaseOrderId && c.IsActive)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.SupplierClaim entity) =>
await context.SupplierClaims.AddAsync(entity);
public void Update(Models.SupplierClaim entity) =>
context.SupplierClaims.Update(entity);
}
@@ -0,0 +1,136 @@
using Distribution.Features.Grn;
using Distribution.Features.Po;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.SupplierClaims;
public class SupplierClaimService(
ISupplierClaimRepository repo,
IPoRepository poRepo,
IGrnRepository grnRepo,
IUnitOfWork uow)
{
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.IssueType);
private static SupplierClaimResponse Map(SupplierClaim c) =>
new(c.Id, c.FK_PurchaseOrderId, c.FK_GoodsReceiptId, c.Details, c.EvidenceUrl, c.Status,
c.ResolutionType, c.ResolutionAmount, c.ResolutionNotes, c.ResolvedAt, c.IsActive, c.CreatedAt,
c.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SupplierClaimResponse?> GetByIdAsync(Guid id)
{
var c = await repo.GetByIdAsync(id);
if (c is null || !c.IsActive) return null;
return Map(c);
}
public async Task<List<SupplierClaimResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
public async Task<SupplierClaimResponse> CreateAsync(Guid purchaseOrderId, CreateSupplierClaimRequest request, Guid raisedByUserId, CancellationToken ct = default)
{
var po = await poRepo.GetByIdAsync(purchaseOrderId);
if (po is null || !po.IsActive)
throw new InvalidOperationException("Purchase order not found.");
if (string.IsNullOrWhiteSpace(request.Details))
throw new InvalidOperationException("Claim details are required.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A supplier claim must have at least one line item.");
if (request.GoodsReceiptId is not null)
{
var grn = await grnRepo.GetByIdAsync(request.GoodsReceiptId.Value)
?? throw new InvalidOperationException("Goods receipt not found.");
if (grn.FK_PurchaseOrderId != purchaseOrderId)
throw new InvalidOperationException("Goods receipt does not belong to this purchase order.");
}
var claim = new SupplierClaim
{
FK_PurchaseOrderId = purchaseOrderId,
FK_GoodsReceiptId = request.GoodsReceiptId,
Details = request.Details,
EvidenceUrl = request.EvidenceUrl,
RaisedByUserId = raisedByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Claim quantity must be greater than zero.");
claim.Items.Add(new SupplierClaimItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
IssueType = line.IssueType
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(claim.Id);
return Map(saved!);
}
public async Task<SupplierClaimResponse> ApproveAsync(Guid id, ApproveSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Supplier claim not found.");
if (claim.Status != SupplierClaimStatus.Open)
throw new InvalidOperationException("Only an open claim can be approved.");
if (request.ResolutionType != SupplierClaimResolutionType.Replacement && request.ResolutionAmount is null)
throw new InvalidOperationException("ResolutionAmount is required for Credit, Refund, and PriceAdjustment.");
claim.Status = SupplierClaimStatus.Approved;
claim.ResolutionType = request.ResolutionType;
claim.ResolutionAmount = request.ResolutionType == SupplierClaimResolutionType.Replacement ? null : request.ResolutionAmount;
claim.ResolutionNotes = request.ResolutionNotes;
claim.ResolvedByUserId = approverId;
claim.ResolvedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Supplier claim not found.");
if (claim.Status != SupplierClaimStatus.Open)
throw new InvalidOperationException("Only an open claim can be rejected.");
claim.Status = SupplierClaimStatus.Rejected;
claim.ResolutionNotes = request.ResolutionNotes;
claim.ResolvedByUserId = approverId;
claim.ResolvedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
}
+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,21 @@
using System.Net.Http.Json;
namespace Distribution.Infra.Authorization;
/// <summary>
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
/// </summary>
public class AuthHexClient(HttpClient http) : IAuthHexClient
{
public async Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default)
{
var body = new { functionName, payload, reference };
var response = await http.PostAsJsonAsync("/api/user", body, ct);
var result = await response.Content.ReadFromJsonAsync<AuthHexResponse>(cancellationToken: ct);
return result ?? new AuthHexResponse((int)response.StatusCode, false, "Empty response from AuthHex.", null);
}
}
@@ -0,0 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Distribution.Infra.Authorization;
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
public record AuthHexResponse(
[property: JsonPropertyName("statusCode")] int StatusCode,
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("data")] JsonElement? Data);
@@ -0,0 +1,6 @@
namespace Distribution.Infra.Authorization;
public interface IAuthHexClient
{
Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default);
}
@@ -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,6 +0,0 @@
namespace Distribution.Infra.Authorization;
public interface IJwtTokenService
{
string GenerateToken(Models.User user);
}
@@ -1,47 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Infra.Authorization;
public class JwtTokenService : IJwtTokenService
{
private readonly IConfiguration _configuration;
public JwtTokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateToken(Models.User user)
{
var jwtSection = _configuration.GetSection("Jwt");
var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
var issuer = jwtSection["Issuer"];
var audience = jwtSection["Audience"];
var expiryMinutes = int.TryParse(jwtSection["ExpiryMinutes"], out var m) ? m : 120;
var claims = new List<Claim>
{
new("sub", user.Id.ToString()),
new("username", user.Username),
new("role", user.Role?.Name ?? string.Empty),
new("role_id", user.FK_RoleId.ToString()),
new("role_level", (user.Role?.Level ?? 0).ToString())
};
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.Now.AddMinutes(expiryMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,32 @@
using System.Security.Cryptography;
using System.Xml.Linq;
namespace Distribution.Infra.Authorization;
/// <summary>
/// Parses an XML-format RSA public key (`&lt;RSAKeyValue&gt;...&lt;/RSAKeyValue&gt;`, as issued by
/// AuthHex) into an <see cref="RSA"/> instance. Written by hand instead of relying on
/// RSA.FromXmlString, which is Windows-only in .NET (throws PlatformNotSupportedException on
/// Linux/macOS) — this keeps token validation portable across hosts.
/// </summary>
public static class RsaXmlKeyReader
{
public static RSA ReadPublicKey(string xml)
{
var root = XElement.Parse(xml);
var modulus = root.Element("Modulus")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Modulus>.");
var exponent = root.Element("Exponent")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Exponent>.");
var parameters = new RSAParameters
{
Modulus = Convert.FromBase64String(modulus),
Exponent = Convert.FromBase64String(exponent)
};
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return rsa;
}
}
@@ -0,0 +1,10 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>Stand-in for OSMCore's OMSCore.Common.Models.IOs.ApiResponse&lt;T&gt;, which has no equivalent in this project.</summary>
public sealed record ApiResponse<T>
{
public int StatusCode { get; init; }
public bool Success { get; init; }
public string? Message { get; init; }
public T? Data { get; init; }
}
@@ -0,0 +1,185 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/AuthMiddleware.cs (global path-skip-based
/// middleware). NOT registered in Program.cs — this project's actual token validation is the
/// standard AddJwtBearer handler (see Program.cs), same as OSMCore's own recommendation. Kept here
/// unregistered, exactly like OSMCore's own copy, for reference/reuse if a project needs a
/// middleware-based (rather than AddJwtBearer-based) validation entry point.
///
/// Two adaptations from the OSMCore original, both required for this to actually work against
/// this project's authorization system rather than just compiling:
/// 1. RSA key parsing goes through <see cref="RsaXmlKeyReader"/> instead of RSA.FromXmlString,
/// which is Windows-only in modern .NET (see docs/authentication_flow.md §2.2 Gotcha).
/// 2. After validating the token, the user id is mapped onto a local User/Role row and
/// role_id/role_level/username claims are attached — the same step Program.cs's
/// OnTokenValidated performs for the AddJwtBearer path. Without this, a request authenticated
/// through this middleware would have no role_id claim, and every
/// [Authorize(Policy = "resource.action")] check (PermissionAuthorizationHandler) would fail.
/// </summary>
public sealed class AuthMiddleware(RequestDelegate next, IConfiguration configuration, ILogger<AuthMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
if (ShouldSkipAuthentication(context.Request.Path))
{
await next(context);
return;
}
if (!TryGetToken(context, out var token))
{
await RejectAsync(context, "Authentication token is missing.");
return;
}
try
{
var principal = ValidateToken(token);
var userRepo = context.RequestServices.GetRequiredService<IUserRepository>();
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
{
await RejectAsync(context, "User not found or inactive.");
return;
}
context.User = principal;
PopulateContextItems(context, principal);
await next(context);
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException)
{
logger.LogInformation(ex, "JWT validation failed for request path {Path}", context.Request.Path);
await RejectAsync(context, "Invalid or expired token.");
}
}
private ClaimsPrincipal ValidateToken(string token)
{
var jwtOptions = configuration.GetSection("Jwt").Get<JwtOptions>()
?? throw new InvalidOperationException("Jwt configuration is missing.");
if (string.IsNullOrWhiteSpace(jwtOptions.PublicKey))
{
throw new InvalidOperationException("Jwt:PublicKey configuration is missing.");
}
using var rsa = RsaXmlKeyReader.ReadPublicKey(jwtOptions.PublicKey);
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(rsa),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
return handler.ValidateToken(token, parameters, out _);
}
/// <summary>Mirrors Program.cs's OnTokenValidated mapping — see class remarks.</summary>
private static async Task<bool> TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo)
{
var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(subClaim, out var userId))
return false;
var user = await userRepo.GetByIdAsync(userId);
if (user is null || !user.IsActive)
return false;
var identity = (ClaimsIdentity)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()));
return true;
}
private static bool TryGetToken(HttpContext context, out string token)
{
token = string.Empty;
if (TryGetBearerToken(context.Request.Headers.Authorization, out token))
{
return true;
}
var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault();
if (TryGetBearerToken(headerToken, out token))
{
return true;
}
var cookieToken = context.Request.Cookies["access_token"];
if (!string.IsNullOrWhiteSpace(cookieToken))
{
token = cookieToken.Trim();
return true;
}
return false;
}
private static bool TryGetBearerToken(string? authorizationHeader, out string token)
{
token = string.Empty;
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return false;
}
const string bearerPrefix = "Bearer ";
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
token = authorizationHeader.Trim();
return !string.IsNullOrWhiteSpace(token);
}
token = authorizationHeader[bearerPrefix.Length..].Trim();
return !string.IsNullOrWhiteSpace(token);
}
/// <summary>
/// Adapted from OSMCore's path list: this project has no Shopify-style webhook route, so that
/// skip is dropped; /api/auth is narrowed to just login/register (this project's /api/auth/me
/// and /api/auth/logout are meant to require auth, unlike a blanket "/api/auth" skip).
/// </summary>
private static bool ShouldSkipAuthentication(PathString path)
=> path.Equals("/health", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/api/auth/register", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase);
private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal)
{
context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
context.Items["Username"] = principal.FindFirst("username")?.Value;
context.Items["RoleId"] = principal.FindFirst("role_id")?.Value;
context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value;
}
private static async Task RejectAsync(HttpContext context, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
{
StatusCode = StatusCodes.Status401Unauthorized,
Success = false,
Message = message,
Data = null
});
}
}
@@ -0,0 +1,191 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/JwtMiddleware.cs (action-filter attribute pattern,
/// applied per controller/action as [JwtMiddleware.CustomMiddleware]). Not applied anywhere in
/// this project — the standard AddJwtBearer handler (Program.cs) is this project's actual
/// validation path.
///
/// docs/authentication_flow.md explicitly calls out not running this alongside AuthMiddleware or
/// AddJwtBearer at the same time — all three independently re-validate the same RSA-signed token,
/// which is wasteful and error-prone (this is the same duplication OSMCore's own guide flags as a
/// Gotcha). Kept here, unregistered, only as a ported reference implementation.
///
/// Same two adaptations as AuthMiddleware: portable RSA parsing via
/// <see cref="RsaXmlKeyReader"/>, and local User/Role → role_id/role_level claim mapping after
/// validation (see AuthMiddleware's remarks for why this is required, not optional).
/// </summary>
public sealed class JwtMiddleware
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var configuration = context.HttpContext.RequestServices.GetService<IConfiguration>();
if (!TryGetToken(context.HttpContext, out var token))
{
await RejectAsync(context.HttpContext, "Authentication token is missing.");
return;
}
try
{
if (configuration is null)
{
throw new InvalidOperationException("Jwt configuration is missing.");
}
var principal = ValidateToken(token, configuration);
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
{
await RejectAsync(context.HttpContext, "User not found or inactive.");
return;
}
context.HttpContext.User = principal;
PopulateContextItems(context.HttpContext, principal);
await next();
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException or InvalidOperationException)
{
await RejectAsync(context.HttpContext, "Invalid or expired token.");
}
}
private static ClaimsPrincipal ValidateToken(string token, IConfiguration configuration)
{
var xmlKey = configuration["Jwt:PublicKey"];
if (string.IsNullOrWhiteSpace(xmlKey))
throw new SecurityTokenException("JWT public key not configured.");
using var rsa = RsaXmlKeyReader.ReadPublicKey(xmlKey);
var rsaParameters = rsa.ExportParameters(false);
var key = new RsaSecurityKey(rsaParameters)
{
KeyId = ComputeKeyId(rsaParameters)
};
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = configuration["Jwt:Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
return handler.ValidateToken(token, parameters, out _);
}
/// <summary>Mirrors Program.cs's OnTokenValidated mapping — see class remarks.</summary>
private static async Task<bool> TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo)
{
var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(subClaim, out var userId))
return false;
var user = await userRepo.GetByIdAsync(userId);
if (user is null || !user.IsActive)
return false;
var identity = (ClaimsIdentity)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()));
return true;
}
private static bool TryGetToken(HttpContext context, out string token)
{
token = string.Empty;
if (TryGetBearerToken(context.Request.Headers.Authorization, out token))
{
return true;
}
var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault();
if (TryGetBearerToken(headerToken, out token))
{
return true;
}
var cookieToken = context.Request.Cookies["access_token"];
if (!string.IsNullOrWhiteSpace(cookieToken))
{
token = cookieToken.Trim();
return true;
}
return false;
}
private static bool TryGetBearerToken(string? authorizationHeader, out string token)
{
token = string.Empty;
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return false;
}
const string bearerPrefix = "Bearer ";
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
token = authorizationHeader.Trim();
return !string.IsNullOrWhiteSpace(token);
}
token = authorizationHeader[bearerPrefix.Length..].Trim();
return !string.IsNullOrWhiteSpace(token);
}
private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal)
{
context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
context.Items["Username"] = principal.FindFirst("username")?.Value;
context.Items["RoleId"] = principal.FindFirst("role_id")?.Value;
context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value;
}
private static async Task RejectAsync(HttpContext context, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
{
StatusCode = StatusCodes.Status401Unauthorized,
Success = false,
Message = message,
Data = null
});
}
private static string ComputeKeyId(RSAParameters publicParameters)
{
var modulus = publicParameters.Modulus ?? [];
var exponent = publicParameters.Exponent ?? [];
var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}";
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
return Base64UrlEncoder.Encode(hash);
}
}
}
@@ -0,0 +1,13 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/JwtOptions.cs. Binds to the same "Jwt" config
/// section already used by Program.cs's AddJwtBearer setup (Jwt:PublicKey/Audience/Issuer) — no
/// new config keys needed.
/// </summary>
public sealed record JwtOptions
{
public string PublicKey { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public string Issuer { get; init; } = string.Empty;
}
@@ -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;
// }
//}

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