Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5acbab76 | |||
| 29f181d5e1 | |||
| dc7e7a70fc | |||
| 83d3272784 | |||
| 1bee5f91c6 | |||
| 08ad17f15b | |||
| 97a1db1343 | |||
| 3268a50c3e | |||
| f1c036d1d8 | |||
| 92fef1dfa8 | |||
| bb33928661 | |||
| b279a121ad | |||
| 916a2b8f0a | |||
| 3caacf44d0 | |||
| fe9ccb4de7 | |||
| f244a04dc0 | |||
| aa543d0d0d | |||
| 87146e4662 | |||
| cefefcbc49 | |||
| fcca03d0b2 | |||
| 6d8b6b3e59 | |||
| 3837facb02 | |||
| a833586e83 | |||
| ade41b4a60 | |||
| 3680fc9035 | |||
| f6211a8edd | |||
| 83bf715716 |
+1
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<UomConversion> UomConversions { get; set; }
|
public DbSet<UomConversion> UomConversions { get; set; }
|
||||||
public DbSet<Item> Items { get; set; }
|
public DbSet<Item> Items { get; set; }
|
||||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||||
|
public DbSet<Sale> Sales { get; set; }
|
||||||
public DbSet<Order> Orders { get; set; }
|
public DbSet<Order> Orders { get; set; }
|
||||||
public DbSet<OrderItem> OrderItems { get; set; }
|
public DbSet<OrderItem> OrderItems { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
@@ -41,12 +42,20 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
||||||
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
||||||
public DbSet<LoadingSheetItem> LoadingSheetItems { 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<PurchaseOrder> PurchaseOrders { get; set; }
|
||||||
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||||
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||||
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||||
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||||
public DbSet<PurchaseReturnItem> PurchaseReturnItems { 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; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -79,6 +88,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>()
|
||||||
|
.HasIndex(o => o.FK_SaleId)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<DriverProfile>()
|
modelBuilder.Entity<DriverProfile>()
|
||||||
.HasIndex(dp => dp.FK_UserId)
|
.HasIndex(dp => dp.FK_UserId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
@@ -102,6 +115,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(r => r.FK_DriverProfileId)
|
.HasForeignKey(r => r.FK_DriverProfileId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.Scheme)
|
.HasOne(r => r.Scheme)
|
||||||
.WithMany(s => s.Redemptions)
|
.WithMany(s => s.Redemptions)
|
||||||
@@ -238,6 +276,103 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<PurchaseReturnItem>()
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
.HasIndex(i => i.FK_PurchaseReturnId);
|
.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);
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Auth;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[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")]
|
[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);
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
return result is null ? Unauthorized() : Ok(result);
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ namespace Distribution.Features.Auth;
|
|||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
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);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
using Distribution.Features.User;
|
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Auth;
|
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)
|
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
||||||
{
|
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||||
var user = await userRepo.GetByUsernameAsync(request.Username);
|
|
||||||
if (user is null || !user.IsActive)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
public Task<AuthHexResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
|
||||||
if (result == PasswordVerificationResult.Failed)
|
authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
|
||||||
return null;
|
|
||||||
|
|
||||||
var token = tokenService.GenerateToken(user);
|
public Task<AuthHexResponse> LogoutAsync(string token, CancellationToken ct = default) =>
|
||||||
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
|
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<AuthServiceClient> 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;
|
namespace Distribution.Features.Brand;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/brands")]
|
[Route("api/brands")]
|
||||||
[Authorize]
|
|
||||||
public class BrandController(BrandService service) : ControllerBase
|
public class BrandController(BrandService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, 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;
|
namespace Distribution.Features.Category;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class CategoryController(CategoryService service) : ControllerBase
|
public class CategoryController(CategoryService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/categories")]
|
[HttpGet("api/categories")]
|
||||||
[Authorize(Policy = "categories.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("api/categories/{id:guid}")]
|
[HttpGet("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/categories")]
|
[HttpPost("api/categories")]
|
||||||
[Authorize(Policy = "categories.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/categories/{id:guid}")]
|
[HttpPut("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("api/categories/{id:guid}")]
|
[HttpDelete("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
||||||
[Authorize(Policy = "categories.create")]
|
|
||||||
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
||||||
@@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/subcategories/{id:guid}")]
|
[HttpPut("api/subcategories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.update")]
|
|
||||||
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
||||||
@@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("api/subcategories/{id:guid}")]
|
[HttpDelete("api/subcategories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.delete")]
|
|
||||||
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteSubCategoryAsync(id, 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 }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/driver-profiles")]
|
[Route("api/driver-profiles")]
|
||||||
[Authorize]
|
public class DriverProfileController(
|
||||||
public class DriverProfileController(DriverProfileService service) : ControllerBase
|
DriverProfileService service,
|
||||||
|
DriverComplianceConfigService complianceService,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "driver_profiles.view")]
|
|
||||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
{
|
{
|
||||||
var result = await service.GetAllAsync(page, pageSize);
|
var result = await service.GetAllAsync(page, pageSize);
|
||||||
@@ -17,7 +19,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -25,15 +26,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "driver_profiles.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
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}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -41,10 +51,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.deactivate")]
|
|
||||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var success = await service.DeactivateAsync(id, ct);
|
var success = await service.DeactivateAsync(id, ct);
|
||||||
return success ? NoContent() : NotFound();
|
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);
|
DateTime CreatedAt);
|
||||||
|
|
||||||
public record CreateDriverProfileRequest(
|
public record CreateDriverProfileRequest(
|
||||||
Guid FK_UserId,
|
Guid UserId,
|
||||||
string LicenseNumber,
|
string LicenseNumber,
|
||||||
DateTime LicenseExpiry,
|
DateTime LicenseExpiry,
|
||||||
DateTime MedicalClearanceExpiry);
|
DateTime MedicalClearanceExpiry);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.User;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
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)
|
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)
|
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)
|
if (existing is not null && existing.IsActive)
|
||||||
throw new InvalidOperationException("An active driver profile already exists for this user");
|
throw new InvalidOperationException("An active driver profile already exists for this user");
|
||||||
|
|
||||||
var profile = new global::Distribution.Models.DriverProfile
|
var profile = new global::Distribution.Models.DriverProfile
|
||||||
{
|
{
|
||||||
FK_UserId = request.FK_UserId,
|
FK_UserId = request.UserId,
|
||||||
LicenseNumber = request.LicenseNumber,
|
LicenseNumber = request.LicenseNumber,
|
||||||
LicenseExpiry = request.LicenseExpiry,
|
LicenseExpiry = request.LicenseExpiry,
|
||||||
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Grn;
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
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}")]
|
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
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);
|
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")]
|
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int
|
|||||||
|
|
||||||
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
||||||
|
|
||||||
|
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
|
||||||
|
|
||||||
public record GrnItemResponse(
|
public record GrnItemResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid PurchaseOrderItemId,
|
Guid PurchaseOrderItemId,
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
.FirstOrDefaultAsync(gr => gr.Id == id);
|
.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) =>
|
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.Include(gr => gr.Warehouse)
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ public class GrnService(
|
|||||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
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());
|
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)
|
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var gr = await repo.GetByIdAsync(id);
|
var gr = await repo.GetByIdAsync(id);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ namespace Distribution.Features.Grn;
|
|||||||
public interface IGrnRepository
|
public interface IGrnRepository
|
||||||
{
|
{
|
||||||
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
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<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
Task AddAsync(Models.GoodsReceipt entity);
|
Task AddAsync(Models.GoodsReceipt entity);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Item;
|
namespace Distribution.Features.Item;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/items")]
|
[Route("api/items")]
|
||||||
[Authorize]
|
|
||||||
public class ItemController(ItemService service) : ControllerBase
|
public class ItemController(ItemService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] Guid? categoryId,
|
[FromQuery] Guid? categoryId,
|
||||||
[FromQuery] Guid? subCategoryId,
|
[FromQuery] Guid? subCategoryId,
|
||||||
@@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/uom-conversions")]
|
[HttpPut("{id:guid}/uom-conversions")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.LoadingEvent;
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/loading-sheets")]
|
[Route("api/loading-sheets")]
|
||||||
[Authorize]
|
|
||||||
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "loading.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] Guid? rideId,
|
[FromQuery] Guid? rideId,
|
||||||
[FromQuery] LoadingSheetStatus? status,
|
[FromQuery] LoadingSheetStatus? status,
|
||||||
@@ -23,7 +20,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "loading.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -31,7 +27,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "loading.dispatch")]
|
|
||||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -44,7 +39,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/items")]
|
[HttpPut("{id:guid}/items")]
|
||||||
[Authorize(Policy = "loading.dispatch")]
|
|
||||||
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -57,7 +51,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/confirm-dispatch")]
|
[HttpPost("{id:guid}/confirm-dispatch")]
|
||||||
[Authorize(Policy = "loading.dispatch")]
|
|
||||||
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -71,7 +64,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/receipt-items")]
|
[HttpPut("{id:guid}/receipt-items")]
|
||||||
[Authorize(Policy = "loading.receive")]
|
|
||||||
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -84,7 +76,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/confirm-receipt")]
|
[HttpPost("{id:guid}/confirm-receipt")]
|
||||||
[Authorize(Policy = "loading.receive")]
|
|
||||||
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -98,7 +89,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/resolve")]
|
[HttpPost("{id:guid}/resolve")]
|
||||||
[Authorize(Policy = "loading.resolve")]
|
|
||||||
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -110,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
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 ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
||||||
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
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)
|
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
|
||||||
{
|
{
|
||||||
var groups = await context.OrderItems
|
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)
|
.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();
|
.ToListAsync();
|
||||||
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
|
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,98 @@ public class LoadingSheetService(
|
|||||||
return ToResponse(sheet);
|
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)
|
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))
|
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<Models.Order?> GetByIdAsync(Guid id);
|
||||||
Task<List<Models.Order>> GetAllAsync();
|
Task<List<Models.Order>> GetAllAsync();
|
||||||
|
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
|
||||||
Task AddAsync(Models.Order entity);
|
Task AddAsync(Models.Order entity);
|
||||||
void Update(Models.Order entity);
|
void Update(Models.Order entity);
|
||||||
void Remove(Models.Order entity);
|
void Remove(Models.Order entity);
|
||||||
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
||||||
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
||||||
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
||||||
|
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/orders")]
|
[Route("api/orders")]
|
||||||
[Authorize]
|
|
||||||
public class OrderController(OrderService service) : ControllerBase
|
public class OrderController(OrderService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("store/{storeId:guid}")]
|
[HttpGet("store/{storeId:guid}")]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByStoreIdAsync(storeId);
|
var result = await service.GetByStoreIdAsync(storeId);
|
||||||
@@ -30,23 +25,28 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "orders.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
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}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
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, int QuantityFulfilled, decimal UnitPrice);
|
||||||
|
|
||||||
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
|
|
||||||
|
|
||||||
public record OrderResponse(
|
public record OrderResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
Guid SaleId,
|
||||||
DateTime OrderDate,
|
DateTime OrderDate,
|
||||||
Guid StoreId,
|
Guid StoreId,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
|
OrderStatus Status,
|
||||||
|
DateTime? FulfilledAt,
|
||||||
List<OrderItemResponse> Items,
|
List<OrderItemResponse> Items,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
public async Task<List<Models.Order>> GetAllAsync() =>
|
public async Task<List<Models.Order>> GetAllAsync() =>
|
||||||
await context.Orders.ToListAsync();
|
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) =>
|
public async Task AddAsync(Models.Order entity) =>
|
||||||
await context.Orders.AddAsync(entity);
|
await context.Orders.AddAsync(entity);
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
|
|
||||||
public void Remove(Models.Order entity)
|
public void Remove(Models.Order entity)
|
||||||
{
|
{
|
||||||
|
entity.Status = Models.OrderStatus.Cancelled;
|
||||||
entity.IsActive = false;
|
entity.IsActive = false;
|
||||||
context.Orders.Update(entity);
|
context.Orders.Update(entity);
|
||||||
}
|
}
|
||||||
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
|
|
||||||
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
|
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
|
||||||
await context.OrderItems.AddRangeAsync(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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Features.Sale;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
@@ -5,7 +6,6 @@ using Distribution.Features.Stock;
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
@@ -15,16 +15,16 @@ public class OrderService(
|
|||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IScopeAccessService scopeAccess,
|
IScopeAccessService scopeAccess)
|
||||||
IConfiguration configuration)
|
|
||||||
{
|
{
|
||||||
private const int GlobalScopeRoleLevel = 50;
|
private const int GlobalScopeRoleLevel = 50;
|
||||||
|
|
||||||
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
||||||
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
|
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.UnitPrice)).ToList(),
|
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
|
||||||
o.IsActive, o.CreatedAt);
|
o.IsActive, o.CreatedAt);
|
||||||
|
|
||||||
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||||
@@ -35,42 +35,6 @@ public class OrderService(
|
|||||||
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
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()
|
public async Task<List<OrderResponse>> GetAllAsync()
|
||||||
{
|
{
|
||||||
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
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)
|
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);
|
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
|
||||||
if (store is null || !store.IsActive) return null;
|
?? throw new InvalidOperationException("Orders are created automatically from sales");
|
||||||
if (store.KycStatus != KycStatus.Verified)
|
|
||||||
{
|
|
||||||
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
|
||||||
if (request.TotalAmount > threshold) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var order = new Models.Order
|
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
|
||||||
{
|
return ToResponse(existingOrder, items);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
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);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return null;
|
if (order is null || !order.IsActive) return null;
|
||||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) 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);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
repo.Update(order);
|
repo.Update(order);
|
||||||
|
saleRepo.Update(sale);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -178,6 +119,7 @@ public class OrderService(
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = await repo.GetItemsByOrderIdAsync(id);
|
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||||
return ToResponse(order, items);
|
return ToResponse(order, items);
|
||||||
}
|
}
|
||||||
@@ -187,10 +129,19 @@ public class OrderService(
|
|||||||
var order = await repo.GetByIdAsync(id);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return false;
|
if (order is null || !order.IsActive) return false;
|
||||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
|
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
|
||||||
|
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
repo.Remove(order);
|
repo.Remove(order);
|
||||||
|
if (sale is not null)
|
||||||
|
{
|
||||||
|
sale.Status = SaleStatus.Cancelled;
|
||||||
|
sale.CompletedAt = null;
|
||||||
|
saleRepo.Update(sale);
|
||||||
|
}
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
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;
|
namespace Distribution.Features.PriceList;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/price-lists")]
|
[Route("api/price-lists")]
|
||||||
[Authorize]
|
|
||||||
public class PriceListController(PriceListService service) : ControllerBase
|
public class PriceListController(PriceListService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Po;
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/purchase-orders")]
|
[Route("api/purchase-orders")]
|
||||||
[Authorize]
|
|
||||||
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.PurchaseReturn;
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/purchase-returns/{id:guid}")]
|
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Region;
|
namespace Distribution.Features.Region;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/regions")]
|
[Route("api/regions")]
|
||||||
[Authorize]
|
|
||||||
public class RegionController(RegionService service) : ControllerBase
|
public class RegionController(RegionService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "regions.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "regions.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Ride;
|
namespace Distribution.Features.Ride;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/rides")]
|
[Route("api/rides")]
|
||||||
[Authorize]
|
|
||||||
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] RideStatus? status,
|
[FromQuery] RideStatus? status,
|
||||||
[FromQuery] RideType? type,
|
[FromQuery] RideType? type,
|
||||||
@@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "rides.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -41,7 +36,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/driver")]
|
[HttpPut("{id:guid}/driver")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/open")]
|
[HttpPost("{id:guid}/open")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -66,8 +59,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/activate")]
|
||||||
[HttpPost("{id:guid}/activate")]
|
[HttpPost("{id:guid}/activate")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -79,8 +72,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/close")]
|
||||||
[HttpPost("{id:guid}/close")]
|
[HttpPost("{id:guid}/close")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -93,7 +86,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/cancel-request")]
|
[HttpPost("{id:guid}/cancel-request")]
|
||||||
[Authorize(Policy = "rides.cancel.request")]
|
|
||||||
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -107,7 +99,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("cancel-requests")]
|
[HttpGet("cancel-requests")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetCancelRequests(
|
public async Task<IActionResult> GetCancelRequests(
|
||||||
[FromQuery] CancelRequestStatus? status,
|
[FromQuery] CancelRequestStatus? status,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
@@ -118,7 +109,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/approve")]
|
[HttpPost("cancel-requests/{id:guid}/approve")]
|
||||||
[Authorize(Policy = "rides.cancel.approve")]
|
|
||||||
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -132,7 +122,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/reject")]
|
[HttpPost("cancel-requests/{id:guid}/reject")]
|
||||||
[Authorize(Policy = "rides.cancel.approve")]
|
|
||||||
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -146,7 +135,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/cancel-request")]
|
[HttpGet("{id:guid}/cancel-request")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetCancelRequest(Guid id)
|
public async Task<IActionResult> GetCancelRequest(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetCancelRequestAsync(id);
|
var result = await service.GetCancelRequestAsync(id);
|
||||||
@@ -154,7 +142,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("breakdown-assist/{rideId:guid}")]
|
[HttpGet("breakdown-assist/{rideId:guid}")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Role;
|
namespace Distribution.Features.Role;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/roles")]
|
[Route("api/roles")]
|
||||||
[Authorize]
|
|
||||||
public class RoleController(RoleService service) : ControllerBase
|
public class RoleController(RoleService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Route;
|
namespace Distribution.Features.Route;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/routes")]
|
[Route("api/routes")]
|
||||||
[Authorize]
|
|
||||||
public class RouteController(RouteService service) : ControllerBase
|
public class RouteController(RouteService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "routes.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "routes.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class RouteController(RouteService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "routes.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class RouteController(RouteService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "routes.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class RouteController(RouteService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "routes.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
namespace Distribution.Features.Scheme;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/schemes")]
|
[Route("api/schemes")]
|
||||||
[Authorize]
|
|
||||||
public class SchemeController(SchemeService service) : ControllerBase
|
public class SchemeController(SchemeService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -120,7 +118,6 @@ public class SchemeController(SchemeService service) : ControllerBase
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/orders")]
|
[Route("api/orders")]
|
||||||
[Authorize]
|
|
||||||
public class SchemeOrderController(SchemeService service) : ControllerBase
|
public class SchemeOrderController(SchemeService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("{id:guid}/apply-scheme")]
|
[HttpPost("{id:guid}/apply-scheme")]
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Stock;
|
namespace Distribution.Features.Stock;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/stock")]
|
[Route("api/stock")]
|
||||||
[Authorize]
|
|
||||||
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
|
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
// ── Query ──────────────────────────────────────────────────────────────
|
// ── Query ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("balances")]
|
[HttpGet("balances")]
|
||||||
public async Task<IActionResult> GetBalances(
|
public async Task<IActionResult> GetBalances(
|
||||||
@@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Write ──────────────────────────────────────────────────────────────
|
// ── Write ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpPost("transfer")]
|
[HttpPost("transfer")]
|
||||||
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
|
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;
|
namespace Distribution.Features.Store.Kyc;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/stores/{storeId:guid}/kyc-documents")]
|
[Route("api/stores/{storeId:guid}/kyc-documents")]
|
||||||
[Authorize]
|
|
||||||
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
|
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetByStoreId(Guid storeId)
|
public async Task<IActionResult> GetByStoreId(Guid storeId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByStoreIdAsync(storeId);
|
var result = await service.GetByStoreIdAsync(storeId);
|
||||||
@@ -17,7 +14,6 @@ public class StoreKycDocumentController(StoreKycDocumentService service) : Contr
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "stores.update")]
|
|
||||||
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UploadAsync(storeId, request, ct);
|
var result = await service.UploadAsync(storeId, request, ct);
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Store;
|
namespace Distribution.Features.Store;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/stores")]
|
[Route("api/stores")]
|
||||||
[Authorize]
|
|
||||||
public class StoreController(StoreService service) : ControllerBase
|
public class StoreController(StoreService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "stores.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "stores.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "stores.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -46,7 +39,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/credit-limit")]
|
[HttpPut("{id:guid}/credit-limit")]
|
||||||
[Authorize(Policy = "stores.credit.update")]
|
|
||||||
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateCreditLimitAsync(id, request, ct);
|
var result = await service.UpdateCreditLimitAsync(id, request, ct);
|
||||||
@@ -54,7 +46,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/outstanding-balance")]
|
[HttpPut("{id:guid}/outstanding-balance")]
|
||||||
[Authorize(Policy = "stores.credit.update")]
|
|
||||||
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
|
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
|
||||||
@@ -62,7 +53,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/outstanding-balance")]
|
[HttpGet("{id:guid}/outstanding-balance")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetOutstandingBalance(Guid id)
|
public async Task<IActionResult> GetOutstandingBalance(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetOutstandingBalanceAsync(id);
|
var result = await service.GetOutstandingBalanceAsync(id);
|
||||||
@@ -70,7 +60,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/kyc/verify")]
|
[HttpPut("{id:guid}/kyc/verify")]
|
||||||
[Authorize(Policy = "stores.kyc.verify")]
|
|
||||||
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
|
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.VerifyKycAsync(id, request, ct);
|
var result = await service.VerifyKycAsync(id, request, ct);
|
||||||
@@ -78,7 +67,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/segment")]
|
[HttpPut("{id:guid}/segment")]
|
||||||
[Authorize(Policy = "stores.update")]
|
|
||||||
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.ClassifySegmentAsync(id, request, ct);
|
var result = await service.ClassifySegmentAsync(id, request, ct);
|
||||||
@@ -86,7 +74,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/visit-history")]
|
[HttpGet("{id:guid}/visit-history")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetVisitHistory(Guid id)
|
public async Task<IActionResult> GetVisitHistory(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetVisitHistoryAsync(id);
|
var result = await service.GetVisitHistoryAsync(id);
|
||||||
@@ -94,7 +81,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
|
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
|
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
|
||||||
{
|
{
|
||||||
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
|
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
|
||||||
@@ -102,10 +88,16 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/dashboard")]
|
[HttpGet("{id:guid}/dashboard")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||||
{
|
{
|
||||||
var result = await service.GetDashboardAsync(id, period);
|
var result = await service.GetDashboardAsync(id, period);
|
||||||
return result is null ? NotFound() : Ok(result);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
|
|||||||
|
|
||||||
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
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);
|
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;
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/suppliers")]
|
[Route("api/suppliers")]
|
||||||
[Authorize]
|
|
||||||
public class SupplierController(SupplierService service) : ControllerBase
|
public class SupplierController(SupplierService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[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,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Sync;
|
namespace Distribution.Features.Sync;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/sync")]
|
[Route("api/sync")]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public class SyncController(SyncService service) : ControllerBase
|
public class SyncController(SyncService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("uoms")]
|
[HttpPost("uoms")]
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Uom;
|
namespace Distribution.Features.Uom;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/uoms")]
|
[Route("api/uoms")]
|
||||||
[Authorize]
|
|
||||||
public class UomController(UomService service) : ControllerBase
|
public class UomController(UomService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class UomController(UomService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class UomController(UomService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class UomController(UomService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, 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.User;
|
namespace Distribution.Features.User;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
[Authorize]
|
|
||||||
public class UserController(UserService service) : ControllerBase
|
public class UserController(UserService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "users.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "users.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "users.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "users.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/role")]
|
[HttpPut("{id:guid}/role")]
|
||||||
[Authorize(Policy = "users.assign_role")]
|
|
||||||
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.AssignRoleAsync(id, request, ct);
|
var result = await service.AssignRoleAsync(id, request, ct);
|
||||||
@@ -46,7 +39,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/scope")]
|
[HttpPut("{id:guid}/scope")]
|
||||||
[Authorize(Policy = "users.assign_scope")]
|
|
||||||
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var updated = await service.AssignScopeAsync(id, request, ct);
|
var updated = await service.AssignScopeAsync(id, request, ct);
|
||||||
@@ -54,7 +46,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "users.deactivate")]
|
|
||||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deactivated = await service.DeactivateAsync(id, 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;
|
namespace Distribution.Features.Vehicle;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/vehicles")]
|
[Route("api/vehicles")]
|
||||||
[Authorize]
|
|
||||||
public class VehicleController(VehicleService service) : ControllerBase
|
public class VehicleController(VehicleService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "vehicles.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "vehicles.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "vehicles.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "vehicles.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateVehicleRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateVehicleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "vehicles.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, 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;
|
namespace Distribution.Features.Warehouse;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/warehouses")]
|
[Route("api/warehouses")]
|
||||||
[Authorize]
|
|
||||||
public class WarehouseController(WarehouseService service) : ControllerBase
|
public class WarehouseController(WarehouseService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "warehouses.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "warehouses.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "warehouses.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "warehouses.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "warehouses.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, 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;
|
namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/authorization")]
|
[Route("api/authorization")]
|
||||||
[Authorize]
|
|
||||||
public class AuthorizationController(
|
public class AuthorizationController(
|
||||||
IPermissionService permissionService,
|
IPermissionService permissionService,
|
||||||
IScopeAccessService scopeAccessService,
|
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 (`<RSAKeyValue>...</RSAKeyValue>`, 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<T>, 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>
|
// public PermissionAuthorizationHandler(IPermissionService permissionService)
|
||||||
{
|
// {
|
||||||
private readonly IPermissionService _permissionService;
|
// _permissionService = permissionService;
|
||||||
|
// }
|
||||||
|
|
||||||
public PermissionAuthorizationHandler(IPermissionService permissionService)
|
// protected override async Task HandleRequirementAsync(
|
||||||
{
|
// AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||||
_permissionService = permissionService;
|
// {
|
||||||
}
|
// var roleIdClaim = context.User.FindFirst("role_id")?.Value;
|
||||||
|
// if (!Guid.TryParse(roleIdClaim, out var roleId))
|
||||||
|
// return;
|
||||||
|
|
||||||
protected override async Task HandleRequirementAsync(
|
// if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
|
||||||
AuthorizationHandlerContext context, PermissionRequirement requirement)
|
// context.Succeed(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
//public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||||
{
|
//{
|
||||||
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
// private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||||
|
|
||||||
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
// public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||||
{
|
// {
|
||||||
_fallback = new DefaultAuthorizationPolicyProvider(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)
|
// public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||||
{
|
// {
|
||||||
if (policyName.Contains('.'))
|
// if (policyName.Contains('.'))
|
||||||
{
|
// {
|
||||||
var policy = new AuthorizationPolicyBuilder()
|
// var policy = new AuthorizationPolicyBuilder()
|
||||||
.RequireAuthenticatedUser()
|
// .RequireAuthenticatedUser()
|
||||||
.AddRequirements(new PermissionRequirement(policyName))
|
// .AddRequirements(new PermissionRequirement(policyName))
|
||||||
.Build();
|
// .Build();
|
||||||
return Task.FromResult<AuthorizationPolicy?>(policy);
|
// 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 PermissionRequirement(string permissionCode)
|
||||||
{
|
// {
|
||||||
public string PermissionCode { get; }
|
// PermissionCode = permissionCode;
|
||||||
|
// }
|
||||||
public PermissionRequirement(string permissionCode)
|
//}
|
||||||
{
|
|
||||||
PermissionCode = permissionCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Distribution.Middleware;
|
||||||
|
|
||||||
|
public class AuthMiddleware(RequestDelegate next, IConfiguration config)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var token = context.Request.Headers.Authorization
|
||||||
|
.ToString()
|
||||||
|
.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
var key = config["Jwt:Key"];
|
||||||
|
if (!string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var principal = new JwtSecurityTokenHandler().ValidateToken(token,
|
||||||
|
new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = config["Jwt:Issuer"],
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = config["Jwt:Audience"],
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1)
|
||||||
|
}, out _);
|
||||||
|
|
||||||
|
context.User = principal;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1977
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class payment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "FulfilledAt",
|
||||||
|
table: "Orders",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Status",
|
||||||
|
table: "Orders",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "QuantityFulfilled",
|
||||||
|
table: "OrderItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DeliveryTasks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Sequence = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CustomerConfirmed = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
ConfirmedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
Note = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DeliveryTasks", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DeliveryTasks_DeliveryRuns_FK_RideId",
|
||||||
|
column: x => x.FK_RideId,
|
||||||
|
principalTable: "DeliveryRuns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DeliveryTasks_Orders_FK_OrderId",
|
||||||
|
column: x => x.FK_OrderId,
|
||||||
|
principalTable: "Orders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Sales",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
SaleDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PaymentStatus = table.Column<int>(type: "int", nullable: false),
|
||||||
|
FulfillImmediately = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Sales", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Sales_DeliveryRuns_FK_RideId",
|
||||||
|
column: x => x.FK_RideId,
|
||||||
|
principalTable: "DeliveryRuns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Sales_Stores_FK_StoreId",
|
||||||
|
column: x => x.FK_StoreId,
|
||||||
|
principalTable: "Stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Sales_Users_CreatedByUserId",
|
||||||
|
column: x => x.CreatedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Payments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SaleId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
Method = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CollectedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CollectedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Payments", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Payments_Sales_FK_SaleId",
|
||||||
|
column: x => x.FK_SaleId,
|
||||||
|
principalTable: "Sales",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Payments_Stores_FK_StoreId",
|
||||||
|
column: x => x.FK_StoreId,
|
||||||
|
principalTable: "Stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Orders_FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "FK_SaleId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DeliveryTasks_FK_OrderId",
|
||||||
|
table: "DeliveryTasks",
|
||||||
|
column: "FK_OrderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DeliveryTasks_FK_RideId",
|
||||||
|
table: "DeliveryTasks",
|
||||||
|
column: "FK_RideId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Payments_FK_SaleId",
|
||||||
|
table: "Payments",
|
||||||
|
column: "FK_SaleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Payments_FK_StoreId",
|
||||||
|
table: "Payments",
|
||||||
|
column: "FK_StoreId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Sales_CreatedByUserId",
|
||||||
|
table: "Sales",
|
||||||
|
column: "CreatedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Sales_FK_RideId",
|
||||||
|
table: "Sales",
|
||||||
|
column: "FK_RideId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Sales_FK_StoreId",
|
||||||
|
table: "Sales",
|
||||||
|
column: "FK_StoreId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Orders_Sales_FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "FK_SaleId",
|
||||||
|
principalTable: "Sales",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Orders_Sales_FK_SaleId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DeliveryTasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Payments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Sales");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Orders_FK_SaleId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FK_SaleId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FulfilledAt",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Status",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuantityFulfilled",
|
||||||
|
table: "OrderItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReturnsAndClaims : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Claims",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Details = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SettlementAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
|
||||||
|
SettledByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
SettledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Claims", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Claims_Users_FK_AgencyUserId",
|
||||||
|
column: x => x.FK_AgencyUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Claims_Users_SettledByUserId",
|
||||||
|
column: x => x.SettledByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SaleReturns",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Mode = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalCreditAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SaleReturns", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturns_Orders_FK_OrderId",
|
||||||
|
column: x => x.FK_OrderId,
|
||||||
|
principalTable: "Orders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturns_Users_CreatedByUserId",
|
||||||
|
column: x => x.CreatedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CreditNotes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
IssuedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CreditNotes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CreditNotes_SaleReturns_FK_SaleReturnId",
|
||||||
|
column: x => x.FK_SaleReturnId,
|
||||||
|
principalTable: "SaleReturns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SaleReturnItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
ReasonCode = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SaleReturnItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturnItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturnItems_SaleReturns_FK_SaleReturnId",
|
||||||
|
column: x => x.FK_SaleReturnId,
|
||||||
|
principalTable: "SaleReturns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Claims_FK_AgencyUserId",
|
||||||
|
table: "Claims",
|
||||||
|
column: "FK_AgencyUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Claims_SettledByUserId",
|
||||||
|
table: "Claims",
|
||||||
|
column: "SettledByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CreditNotes_FK_SaleReturnId",
|
||||||
|
table: "CreditNotes",
|
||||||
|
column: "FK_SaleReturnId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturnItems_FK_ItemId",
|
||||||
|
table: "SaleReturnItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturnItems_FK_SaleReturnId",
|
||||||
|
table: "SaleReturnItems",
|
||||||
|
column: "FK_SaleReturnId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturns_CreatedByUserId",
|
||||||
|
table: "SaleReturns",
|
||||||
|
column: "CreatedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturns_FK_OrderId",
|
||||||
|
table: "SaleReturns",
|
||||||
|
column: "FK_OrderId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Claims");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CreditNotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SaleReturnItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SaleReturns");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSupplierClaims : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SupplierClaims",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_GoodsReceiptId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
Details = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
EvidenceUrl = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ResolutionType = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ResolutionAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
|
||||||
|
ResolutionNotes = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
RaisedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
ResolvedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
ResolvedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SupplierClaims", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_GoodsReceipts_FK_GoodsReceiptId",
|
||||||
|
column: x => x.FK_GoodsReceiptId,
|
||||||
|
principalTable: "GoodsReceipts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_PurchaseOrders_FK_PurchaseOrderId",
|
||||||
|
column: x => x.FK_PurchaseOrderId,
|
||||||
|
principalTable: "PurchaseOrders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_Users_RaisedByUserId",
|
||||||
|
column: x => x.RaisedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_Users_ResolvedByUserId",
|
||||||
|
column: x => x.ResolvedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SupplierClaimItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SupplierClaimId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
IssueType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SupplierClaimItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaimItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaimItems_SupplierClaims_FK_SupplierClaimId",
|
||||||
|
column: x => x.FK_SupplierClaimId,
|
||||||
|
principalTable: "SupplierClaims",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaimItems_FK_ItemId",
|
||||||
|
table: "SupplierClaimItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaimItems_FK_SupplierClaimId",
|
||||||
|
table: "SupplierClaimItems",
|
||||||
|
column: "FK_SupplierClaimId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_FK_GoodsReceiptId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "FK_GoodsReceiptId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_FK_PurchaseOrderId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_RaisedByUserId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "RaisedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_ResolvedByUserId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "ResolvedByUserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SupplierClaimItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SupplierClaims");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user