Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83d3272784 | |||
| 1bee5f91c6 | |||
| 08ad17f15b | |||
| 97a1db1343 | |||
| 3268a50c3e | |||
| f1c036d1d8 | |||
| 92fef1dfa8 | |||
| bb33928661 | |||
| b279a121ad | |||
| 916a2b8f0a | |||
| 3caacf44d0 | |||
| fe9ccb4de7 | |||
| f244a04dc0 | |||
| aa543d0d0d | |||
| 87146e4662 | |||
| fcca03d0b2 | |||
| 3837facb02 | |||
| ade41b4a60 | |||
| 3680fc9035 | |||
| f6211a8edd | |||
| 83bf715716 |
+1
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
@@ -50,6 +50,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||
public DbSet<SaleReturn> SaleReturns { get; set; }
|
||||
public DbSet<SaleReturnItem> SaleReturnItems { get; set; }
|
||||
public DbSet<CreditNote> CreditNotes { get; set; }
|
||||
public DbSet<Claim> Claims { get; set; }
|
||||
public DbSet<SupplierClaim> SupplierClaims { get; set; }
|
||||
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -270,6 +276,91 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
modelBuilder.Entity<PurchaseReturnItem>()
|
||||
.HasIndex(i => i.FK_PurchaseReturnId);
|
||||
|
||||
modelBuilder.Entity<SaleReturn>()
|
||||
.HasOne(r => r.Order)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FK_OrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturn>()
|
||||
.HasOne(r => r.CreatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.CreatedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturnItem>()
|
||||
.HasOne(i => i.SaleReturn)
|
||||
.WithMany(r => r.Items)
|
||||
.HasForeignKey(i => i.FK_SaleReturnId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturnItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturnItem>()
|
||||
.HasIndex(i => i.FK_SaleReturnId);
|
||||
|
||||
modelBuilder.Entity<CreditNote>()
|
||||
.HasOne(n => n.SaleReturn)
|
||||
.WithMany()
|
||||
.HasForeignKey(n => n.FK_SaleReturnId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Claim>()
|
||||
.HasOne(c => c.AgencyUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_AgencyUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Claim>()
|
||||
.HasOne(c => c.SettledByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.SettledByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.PurchaseOrder)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.GoodsReceipt)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_GoodsReceiptId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.RaisedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.RaisedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.ResolvedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ResolvedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaimItem>()
|
||||
.HasOne(i => i.SupplierClaim)
|
||||
.WithMany(c => c.Items)
|
||||
.HasForeignKey(i => i.FK_SupplierClaimId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaimItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaimItem>()
|
||||
.HasIndex(i => i.FK_SupplierClaimId);
|
||||
|
||||
modelBuilder.Entity<DeliveryTask>()
|
||||
.HasOne(t => t.Ride)
|
||||
.WithMany()
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(AuthService service) : ControllerBase
|
||||
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
private static IActionResult ToActionResult(AuthHexResponse result) =>
|
||||
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login(LoginRequest request)
|
||||
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
|
||||
ToActionResult(await service.LoginAsync(request, ct));
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
|
||||
ToActionResult(await service.RegisterAsync(request, ct));
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me(CancellationToken ct)
|
||||
{
|
||||
var result = await service.LoginAsync(request);
|
||||
return result is null ? Unauthorized() : Ok(result);
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||
{
|
||||
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
|
||||
return ToActionResult(await service.LogoutAsync(token, ct));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ namespace Distribution.Features.Auth;
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
|
||||
public record LoginResponse(string Token, Guid UserId, string Username, string RoleName);
|
||||
public record RegisterRequest(string Username, string Password, string? FullName);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
using Distribution.Features.User;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService)
|
||||
/// <summary>
|
||||
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
|
||||
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
|
||||
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
|
||||
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
|
||||
/// </summary>
|
||||
public class AuthService(IAuthHexClient authHex)
|
||||
{
|
||||
private readonly PasswordHasher<Models.User> _hasher = new();
|
||||
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||
|
||||
public async Task<LoginResponse?> LoginAsync(LoginRequest request)
|
||||
{
|
||||
var user = await userRepo.GetByUsernameAsync(request.Username);
|
||||
if (user is null || !user.IsActive)
|
||||
return null;
|
||||
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||
|
||||
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
return null;
|
||||
public Task<AuthHexResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
|
||||
|
||||
var token = tokenService.GenerateToken(user);
|
||||
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
|
||||
}
|
||||
public Task<AuthHexResponse> LogoutAsync(string token, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("LogoutUser", new { token }, ct: ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Typed HttpClient that proxies requests to the external Auth Service.
|
||||
/// Registered via AddHttpClient<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;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/brands")]
|
||||
[Authorize]
|
||||
public class BrandController(BrandService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "items.create")]
|
||||
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "items.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "items.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Category;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class CategoryController(CategoryService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/categories")]
|
||||
[Authorize(Policy = "categories.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("api/categories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("api/categories")]
|
||||
[Authorize(Policy = "categories.create")]
|
||||
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("api/categories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("api/categories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
||||
[Authorize(Policy = "categories.create")]
|
||||
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
||||
@@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("api/subcategories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.update")]
|
||||
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
||||
@@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("api/subcategories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.delete")]
|
||||
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteSubCategoryAsync(id, ct);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
[ApiController]
|
||||
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/claims/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("api/claims")]
|
||||
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/claims/{id:guid}/settle")]
|
||||
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.SettleAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api")]
|
||||
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Distribution.Infra.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();
|
||||
@@ -18,7 +15,6 @@ public class DriverComplianceConfigController(DriverComplianceConfigService serv
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[Authorize(Policy = "compliance.manage")]
|
||||
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DriverProfile;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/driver-profiles")]
|
||||
[Authorize]
|
||||
public class DriverProfileController(DriverProfileService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "driver_profiles.view")]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(page, pageSize);
|
||||
@@ -17,7 +14,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "driver_profiles.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -25,7 +21,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "driver_profiles.create")]
|
||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -33,7 +28,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "driver_profiles.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -41,7 +35,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "driver_profiles.deactivate")]
|
||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var success = await service.DeactivateAsync(id, ct);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Item;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/items")]
|
||||
[Authorize]
|
||||
public class ItemController(ItemService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? categoryId,
|
||||
[FromQuery] Guid? subCategoryId,
|
||||
@@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "items.create")]
|
||||
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "items.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "items.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/uom-conversions")]
|
||||
[Authorize(Policy = "items.update")]
|
||||
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Models;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.LoadingEvent;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/loading-sheets")]
|
||||
[Authorize]
|
||||
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "loading.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? rideId,
|
||||
[FromQuery] LoadingSheetStatus? status,
|
||||
@@ -23,7 +20,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "loading.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -31,7 +27,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "loading.dispatch")]
|
||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -44,7 +39,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items")]
|
||||
[Authorize(Policy = "loading.dispatch")]
|
||||
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -57,7 +51,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm-dispatch")]
|
||||
[Authorize(Policy = "loading.dispatch")]
|
||||
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -71,7 +64,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/receipt-items")]
|
||||
[Authorize(Policy = "loading.receive")]
|
||||
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -84,7 +76,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm-receipt")]
|
||||
[Authorize(Policy = "loading.receive")]
|
||||
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -98,7 +89,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/resolve")]
|
||||
[Authorize(Policy = "loading.resolve")]
|
||||
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/orders")]
|
||||
[Authorize]
|
||||
public class OrderController(OrderService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class OrderController(OrderService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("store/{storeId:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||
{
|
||||
var result = await service.GetByStoreIdAsync(storeId);
|
||||
@@ -30,7 +25,6 @@ public class OrderController(OrderService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "orders.create")]
|
||||
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -42,7 +36,6 @@ public class OrderController(OrderService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "orders.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -54,7 +47,6 @@ public class OrderController(OrderService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "orders.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/sales/{saleId:guid}/payments")]
|
||||
public class PaymentController(PaymentService service) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.PriceList;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/price-lists")]
|
||||
[Authorize]
|
||||
public class PriceListController(PriceListService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchase-orders")]
|
||||
[Authorize]
|
||||
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.PurchaseReturn;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Region;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/regions")]
|
||||
[Authorize]
|
||||
public class RegionController(RegionService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "regions.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "regions.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "regions.create")]
|
||||
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "regions.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "regions.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -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 Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Ride;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/rides")]
|
||||
[Authorize]
|
||||
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] RideStatus? status,
|
||||
[FromQuery] RideType? type,
|
||||
@@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "rides.create")]
|
||||
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -41,7 +36,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/driver")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/open")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -67,7 +60,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/activate")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -80,7 +72,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/close")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -93,7 +84,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel-request")]
|
||||
[Authorize(Policy = "rides.cancel.request")]
|
||||
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -107,7 +97,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("cancel-requests")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetCancelRequests(
|
||||
[FromQuery] CancelRequestStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
@@ -118,7 +107,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("cancel-requests/{id:guid}/approve")]
|
||||
[Authorize(Policy = "rides.cancel.approve")]
|
||||
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -132,7 +120,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("cancel-requests/{id:guid}/reject")]
|
||||
[Authorize(Policy = "rides.cancel.approve")]
|
||||
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -146,7 +133,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/cancel-request")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetCancelRequest(Guid id)
|
||||
{
|
||||
var result = await service.GetCancelRequestAsync(id);
|
||||
@@ -154,7 +140,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("breakdown-assist/{rideId:guid}")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Role;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/roles")]
|
||||
[Authorize]
|
||||
public class RoleController(RoleService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Route;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/routes")]
|
||||
[Authorize]
|
||||
public class RouteController(RouteService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "routes.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "routes.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "routes.create")]
|
||||
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "routes.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "routes.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace Distribution.Features.Sale;
|
||||
public interface ISaleRepository
|
||||
{
|
||||
Task<Models.Sale?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Sale>> GetAllAsync();
|
||||
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
|
||||
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
|
||||
Task AddAsync(Models.Sale sale);
|
||||
void Update(Models.Sale sale);
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Sale;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/sales")]
|
||||
[Authorize]
|
||||
public class SaleController(SaleService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? rideId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20) =>
|
||||
Ok(await service.GetAllAsync(rideId, page, pageSize));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +21,6 @@ public class SaleController(SaleService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("store/{storeId:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||
{
|
||||
var result = await service.GetByStoreIdAsync(storeId);
|
||||
@@ -30,7 +28,6 @@ public class SaleController(SaleService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "orders.create")]
|
||||
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
|
||||
@@ -9,8 +9,21 @@ public class SaleRepository(AppDBContext context) : ISaleRepository
|
||||
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
|
||||
await context.Sales.FindAsync(id);
|
||||
|
||||
public async Task<List<Models.Sale>> GetAllAsync() =>
|
||||
await context.Sales.ToListAsync();
|
||||
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
|
||||
await context.Sales
|
||||
.Where(s => s.IsActive &&
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
|
||||
await context.Sales
|
||||
.CountAsync(s => s.IsActive &&
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
|
||||
|
||||
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
|
||||
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
|
||||
|
||||
@@ -71,14 +71,14 @@ public class SaleService(
|
||||
sale.IsActive,
|
||||
sale.CreatedAt);
|
||||
|
||||
public async Task<List<SaleResponse>> GetAllAsync()
|
||||
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
|
||||
{
|
||||
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
|
||||
IEnumerable<Guid>? storeIds = null;
|
||||
if (!HasGlobalScope && currentUser.UserId is not null)
|
||||
{
|
||||
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
|
||||
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
|
||||
}
|
||||
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
|
||||
|
||||
var total = await repo.GetCountAsync(rideId, storeIds);
|
||||
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
|
||||
|
||||
var result = new List<SaleResponse>();
|
||||
foreach (var sale in sales)
|
||||
@@ -88,7 +88,7 @@ public class SaleService(
|
||||
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||
result.Add(ToResponse(sale, order, items));
|
||||
}
|
||||
return result;
|
||||
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
|
||||
}
|
||||
|
||||
public async Task<SaleResponse?> GetByIdAsync(Guid id)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/schemes")]
|
||||
[Authorize]
|
||||
public class SchemeController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -120,7 +118,6 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
|
||||
[ApiController]
|
||||
[Route("api/orders")]
|
||||
[Authorize]
|
||||
public class SchemeOrderController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("{id:guid}/apply-scheme")]
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Stock;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stock")]
|
||||
[Authorize]
|
||||
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
// ── Query ──────────────────────────────────────────────────────────────
|
||||
// ── Query ──────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("balances")]
|
||||
public async Task<IActionResult> GetBalances(
|
||||
@@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("transfer")]
|
||||
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Store.Kyc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stores/{storeId:guid}/kyc-documents")]
|
||||
[Authorize]
|
||||
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetByStoreId(Guid storeId)
|
||||
{
|
||||
var result = await service.GetByStoreIdAsync(storeId);
|
||||
@@ -17,7 +14,6 @@ public class StoreKycDocumentController(StoreKycDocumentService service) : Contr
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "stores.update")]
|
||||
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UploadAsync(storeId, request, ct);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stores")]
|
||||
[Authorize]
|
||||
public class StoreController(StoreService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "stores.create")]
|
||||
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "stores.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "stores.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -46,7 +39,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/credit-limit")]
|
||||
[Authorize(Policy = "stores.credit.update")]
|
||||
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateCreditLimitAsync(id, request, ct);
|
||||
@@ -54,7 +46,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/outstanding-balance")]
|
||||
[Authorize(Policy = "stores.credit.update")]
|
||||
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
|
||||
@@ -62,7 +53,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/outstanding-balance")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetOutstandingBalance(Guid id)
|
||||
{
|
||||
var result = await service.GetOutstandingBalanceAsync(id);
|
||||
@@ -70,7 +60,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/kyc/verify")]
|
||||
[Authorize(Policy = "stores.kyc.verify")]
|
||||
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.VerifyKycAsync(id, request, ct);
|
||||
@@ -78,7 +67,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/segment")]
|
||||
[Authorize(Policy = "stores.update")]
|
||||
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ClassifySegmentAsync(id, request, ct);
|
||||
@@ -86,7 +74,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/visit-history")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetVisitHistory(Guid id)
|
||||
{
|
||||
var result = await service.GetVisitHistoryAsync(id);
|
||||
@@ -94,7 +81,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
|
||||
{
|
||||
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
|
||||
@@ -102,7 +88,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/dashboard")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Supplier;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/suppliers")]
|
||||
[Authorize]
|
||||
public class SupplierController(SupplierService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public interface ISupplierClaimRepository
|
||||
{
|
||||
Task<Models.SupplierClaim?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||
Task AddAsync(Models.SupplierClaim entity);
|
||||
void Update(Models.SupplierClaim entity);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
[ApiController]
|
||||
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/supplier-claims/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/claims")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/claims")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(id, request, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/approve")]
|
||||
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ApproveAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/reject")]
|
||||
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.RejectAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public record SupplierClaimItemRequest(Guid ItemId, decimal Quantity, SupplierClaimIssueType IssueType);
|
||||
|
||||
public record CreateSupplierClaimRequest(
|
||||
Guid? GoodsReceiptId,
|
||||
string Details,
|
||||
string? EvidenceUrl,
|
||||
List<SupplierClaimItemRequest> Items);
|
||||
|
||||
public record ApproveSupplierClaimRequest(
|
||||
SupplierClaimResolutionType ResolutionType,
|
||||
decimal? ResolutionAmount,
|
||||
string? ResolutionNotes);
|
||||
|
||||
public record RejectSupplierClaimRequest(string? ResolutionNotes);
|
||||
|
||||
public record SupplierClaimItemResponse(
|
||||
Guid Id,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
decimal Quantity,
|
||||
SupplierClaimIssueType IssueType);
|
||||
|
||||
public record SupplierClaimResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderId,
|
||||
Guid? GoodsReceiptId,
|
||||
string Details,
|
||||
string? EvidenceUrl,
|
||||
SupplierClaimStatus Status,
|
||||
SupplierClaimResolutionType? ResolutionType,
|
||||
decimal? ResolutionAmount,
|
||||
string? ResolutionNotes,
|
||||
DateTime? ResolvedAt,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<SupplierClaimItemResponse> Items);
|
||||
@@ -0,0 +1,25 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public class SupplierClaimRepository(AppDBContext context) : ISupplierClaimRepository
|
||||
{
|
||||
public async Task<Models.SupplierClaim?> GetByIdAsync(Guid id) =>
|
||||
await context.SupplierClaims
|
||||
.Include(c => c.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
await context.SupplierClaims
|
||||
.Include(c => c.Items).ThenInclude(i => i.Item)
|
||||
.Where(c => c.FK_PurchaseOrderId == purchaseOrderId && c.IsActive)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.SupplierClaim entity) =>
|
||||
await context.SupplierClaims.AddAsync(entity);
|
||||
|
||||
public void Update(Models.SupplierClaim entity) =>
|
||||
context.SupplierClaims.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public class SupplierClaimService(
|
||||
ISupplierClaimRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IGrnRepository grnRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.IssueType);
|
||||
|
||||
private static SupplierClaimResponse Map(SupplierClaim c) =>
|
||||
new(c.Id, c.FK_PurchaseOrderId, c.FK_GoodsReceiptId, c.Details, c.EvidenceUrl, c.Status,
|
||||
c.ResolutionType, c.ResolutionAmount, c.ResolutionNotes, c.ResolvedAt, c.IsActive, c.CreatedAt,
|
||||
c.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<SupplierClaimResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var c = await repo.GetByIdAsync(id);
|
||||
if (c is null || !c.IsActive) return null;
|
||||
return Map(c);
|
||||
}
|
||||
|
||||
public async Task<List<SupplierClaimResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||
|
||||
public async Task<SupplierClaimResponse> CreateAsync(Guid purchaseOrderId, CreateSupplierClaimRequest request, Guid raisedByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await poRepo.GetByIdAsync(purchaseOrderId);
|
||||
if (po is null || !po.IsActive)
|
||||
throw new InvalidOperationException("Purchase order not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Details))
|
||||
throw new InvalidOperationException("Claim details are required.");
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A supplier claim must have at least one line item.");
|
||||
|
||||
if (request.GoodsReceiptId is not null)
|
||||
{
|
||||
var grn = await grnRepo.GetByIdAsync(request.GoodsReceiptId.Value)
|
||||
?? throw new InvalidOperationException("Goods receipt not found.");
|
||||
if (grn.FK_PurchaseOrderId != purchaseOrderId)
|
||||
throw new InvalidOperationException("Goods receipt does not belong to this purchase order.");
|
||||
}
|
||||
|
||||
var claim = new SupplierClaim
|
||||
{
|
||||
FK_PurchaseOrderId = purchaseOrderId,
|
||||
FK_GoodsReceiptId = request.GoodsReceiptId,
|
||||
Details = request.Details,
|
||||
EvidenceUrl = request.EvidenceUrl,
|
||||
RaisedByUserId = raisedByUserId
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.Quantity <= 0)
|
||||
throw new InvalidOperationException("Claim quantity must be greater than zero.");
|
||||
|
||||
claim.Items.Add(new SupplierClaimItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
IssueType = line.IssueType
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(claim.Id);
|
||||
return Map(saved!);
|
||||
}
|
||||
|
||||
public async Task<SupplierClaimResponse> ApproveAsync(Guid id, ApproveSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Supplier claim not found.");
|
||||
if (claim.Status != SupplierClaimStatus.Open)
|
||||
throw new InvalidOperationException("Only an open claim can be approved.");
|
||||
|
||||
if (request.ResolutionType != SupplierClaimResolutionType.Replacement && request.ResolutionAmount is null)
|
||||
throw new InvalidOperationException("ResolutionAmount is required for Credit, Refund, and PriceAdjustment.");
|
||||
|
||||
claim.Status = SupplierClaimStatus.Approved;
|
||||
claim.ResolutionType = request.ResolutionType;
|
||||
claim.ResolutionAmount = request.ResolutionType == SupplierClaimResolutionType.Replacement ? null : request.ResolutionAmount;
|
||||
claim.ResolutionNotes = request.ResolutionNotes;
|
||||
claim.ResolvedByUserId = approverId;
|
||||
claim.ResolvedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
|
||||
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Supplier claim not found.");
|
||||
if (claim.Status != SupplierClaimStatus.Open)
|
||||
throw new InvalidOperationException("Only an open claim can be rejected.");
|
||||
|
||||
claim.Status = SupplierClaimStatus.Rejected;
|
||||
claim.ResolutionNotes = request.ResolutionNotes;
|
||||
claim.ResolvedByUserId = approverId;
|
||||
claim.ResolvedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Sync;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/sync")]
|
||||
[Authorize(Policy = "items.create")]
|
||||
public class SyncController(SyncService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("uoms")]
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Uom;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/uoms")]
|
||||
[Authorize]
|
||||
public class UomController(UomService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class UomController(UomService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "items.create")]
|
||||
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class UomController(UomService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "items.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class UomController(UomService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "items.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.User;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
public class UserController(UserService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "users.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "users.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class UserController(UserService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "users.create")]
|
||||
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class UserController(UserService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "users.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class UserController(UserService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/role")]
|
||||
[Authorize(Policy = "users.assign_role")]
|
||||
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignRoleAsync(id, request, ct);
|
||||
@@ -46,7 +39,6 @@ public class UserController(UserService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/scope")]
|
||||
[Authorize(Policy = "users.assign_scope")]
|
||||
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
||||
{
|
||||
var updated = await service.AssignScopeAsync(id, request, ct);
|
||||
@@ -54,7 +46,6 @@ public class UserController(UserService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "users.deactivate")]
|
||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deactivated = await service.DeactivateAsync(id, ct);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Vehicle;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/vehicles")]
|
||||
[Authorize]
|
||||
public class VehicleController(VehicleService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "vehicles.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "vehicles.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "vehicles.create")]
|
||||
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "vehicles.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateVehicleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "vehicles.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Warehouse;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/warehouses")]
|
||||
[Authorize]
|
||||
public class WarehouseController(WarehouseService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "warehouses.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "warehouses.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "warehouses.create")]
|
||||
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "warehouses.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "warehouses.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
|
||||
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
|
||||
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
|
||||
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
|
||||
/// </summary>
|
||||
public class AuthHexClient(HttpClient http) : IAuthHexClient
|
||||
{
|
||||
public async Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default)
|
||||
{
|
||||
var body = new { functionName, payload, reference };
|
||||
var response = await http.PostAsJsonAsync("/api/user", body, ct);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AuthHexResponse>(cancellationToken: ct);
|
||||
return result ?? new AuthHexResponse((int)response.StatusCode, false, "Empty response from AuthHex.", null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
|
||||
public record AuthHexResponse(
|
||||
[property: JsonPropertyName("statusCode")] int StatusCode,
|
||||
[property: JsonPropertyName("success")] bool Success,
|
||||
[property: JsonPropertyName("message")] string? Message,
|
||||
[property: JsonPropertyName("data")] JsonElement? Data);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
public interface IAuthHexClient
|
||||
{
|
||||
Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AuthenticatedAttribute : Attribute, IAuthorizationFilter
|
||||
{
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
if (context.HttpContext.User.Identity?.IsAuthenticated != true)
|
||||
context.Result = new UnauthorizedResult();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/authorization")]
|
||||
[Authorize]
|
||||
public class AuthorizationController(
|
||||
IPermissionService permissionService,
|
||||
IScopeAccessService scopeAccessService,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
string GenerateToken(Models.User user);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
public class JwtTokenService : IJwtTokenService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public JwtTokenService(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public string GenerateToken(Models.User user)
|
||||
{
|
||||
var jwtSection = _configuration.GetSection("Jwt");
|
||||
var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
||||
var issuer = jwtSection["Issuer"];
|
||||
var audience = jwtSection["Audience"];
|
||||
var expiryMinutes = int.TryParse(jwtSection["ExpiryMinutes"], out var m) ? m : 120;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new("sub", user.Id.ToString()),
|
||||
new("username", user.Username),
|
||||
new("role", user.Role?.Name ?? string.Empty),
|
||||
new("role_id", user.FK_RoleId.ToString()),
|
||||
new("role_level", (user.Role?.Level ?? 0).ToString())
|
||||
};
|
||||
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
|
||||
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: DateTime.Now.AddMinutes(expiryMinutes),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Parses an XML-format RSA public key (`<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>
|
||||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
// public PermissionAuthorizationHandler(IPermissionService permissionService)
|
||||
// {
|
||||
// _permissionService = permissionService;
|
||||
// }
|
||||
|
||||
public PermissionAuthorizationHandler(IPermissionService permissionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
// protected override async Task HandleRequirementAsync(
|
||||
// AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||
// {
|
||||
// var roleIdClaim = context.User.FindFirst("role_id")?.Value;
|
||||
// if (!Guid.TryParse(roleIdClaim, out var roleId))
|
||||
// return;
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||
{
|
||||
var roleIdClaim = context.User.FindFirst("role_id")?.Value;
|
||||
if (!Guid.TryParse(roleIdClaim, out var roleId))
|
||||
return;
|
||||
|
||||
if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
// if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
|
||||
// context.Succeed(requirement);
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
//using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
//namespace Distribution.Infra.Authorization;
|
||||
|
||||
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||
{
|
||||
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||
//public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||
//{
|
||||
// private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||
|
||||
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
{
|
||||
_fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
}
|
||||
// public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
// {
|
||||
// _fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
// }
|
||||
|
||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||
// public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||
// public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
{
|
||||
if (policyName.Contains('.'))
|
||||
{
|
||||
var policy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new PermissionRequirement(policyName))
|
||||
.Build();
|
||||
return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||
}
|
||||
// public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
// {
|
||||
// if (policyName.Contains('.'))
|
||||
// {
|
||||
// var policy = new AuthorizationPolicyBuilder()
|
||||
// .RequireAuthenticatedUser()
|
||||
// .AddRequirements(new PermissionRequirement(policyName))
|
||||
// .Build();
|
||||
// return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||
// }
|
||||
|
||||
return _fallback.GetPolicyAsync(policyName);
|
||||
}
|
||||
}
|
||||
// return _fallback.GetPolicyAsync(policyName);
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
//namespace Distribution.Infra.Authorization;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
//public class PermissionRequirement : IAuthorizationRequirement
|
||||
//{
|
||||
// public string PermissionCode { get; }
|
||||
|
||||
public class PermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public string PermissionCode { get; }
|
||||
|
||||
public PermissionRequirement(string permissionCode)
|
||||
{
|
||||
PermissionCode = permissionCode;
|
||||
}
|
||||
}
|
||||
// public PermissionRequirement(string permissionCode)
|
||||
// {
|
||||
// PermissionCode = permissionCode;
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Distribution.Middleware;
|
||||
|
||||
public class AuthMiddleware(RequestDelegate next, IConfiguration config)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var token = context.Request.Headers.Authorization
|
||||
.ToString()
|
||||
.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
var key = config["Jwt:Key"];
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
try
|
||||
{
|
||||
var principal = new JwtSecurityTokenHandler().ValidateToken(token,
|
||||
new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = config["Jwt:Issuer"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = config["Jwt:Audience"],
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
}, out _);
|
||||
|
||||
context.User = principal;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,74 @@ namespace Distribution.Migrations
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Claim", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid>("FK_AgencyUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<DateTime?>("SettledAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("SettledByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal?>("SettlementAmount")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_AgencyUserId");
|
||||
|
||||
b.HasIndex("SettledByUserId");
|
||||
|
||||
b.ToTable("Claims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("FK_SaleReturnId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<DateTime>("IssuedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_SaleReturnId");
|
||||
|
||||
b.ToTable("CreditNotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1006,6 +1074,75 @@ namespace Distribution.Migrations
|
||||
b.ToTable("Sales");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_OrderId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("TotalCreditAmount")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedByUserId");
|
||||
|
||||
b.HasIndex("FK_OrderId");
|
||||
|
||||
b.ToTable("SaleReturns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_SaleReturnId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal>("Quantity")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<int>("ReasonCode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("UnitPrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_ItemId");
|
||||
|
||||
b.HasIndex("FK_SaleReturnId");
|
||||
|
||||
b.ToTable("SaleReturnItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SaleRoute", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1377,6 +1514,98 @@ namespace Distribution.Migrations
|
||||
b.ToTable("Suppliers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("EvidenceUrl")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid?>("FK_GoodsReceiptId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_PurchaseOrderId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<Guid>("RaisedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal?>("ResolutionAmount")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<string>("ResolutionNotes")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int?>("ResolutionType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("ResolvedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_GoodsReceiptId");
|
||||
|
||||
b.HasIndex("FK_PurchaseOrderId");
|
||||
|
||||
b.HasIndex("RaisedByUserId");
|
||||
|
||||
b.HasIndex("ResolvedByUserId");
|
||||
|
||||
b.ToTable("SupplierClaims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_SupplierClaimId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("IssueType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Quantity")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_ItemId");
|
||||
|
||||
b.HasIndex("FK_SupplierClaimId");
|
||||
|
||||
b.ToTable("SupplierClaimItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Uom", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1610,6 +1839,35 @@ namespace Distribution.Migrations
|
||||
b.ToTable("Warehouses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Claim", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.User", "AgencyUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_AgencyUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.User", "SettledByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("SettledByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("AgencyUser");
|
||||
|
||||
b.Navigation("SettledByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_SaleReturnId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SaleReturn");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.DriverProfile", "DriverProfile")
|
||||
@@ -1994,6 +2252,44 @@ namespace Distribution.Migrations
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.User", "CreatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.Order", "Order")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_OrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CreatedByUser");
|
||||
|
||||
b.Navigation("Order");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("FK_SaleReturnId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("SaleReturn");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Item", "FreeItem")
|
||||
@@ -2116,6 +2412,58 @@ namespace Distribution.Migrations
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_GoodsReceiptId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_PurchaseOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.User", "RaisedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("RaisedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.User", "ResolvedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ResolvedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("GoodsReceipt");
|
||||
|
||||
b.Navigation("PurchaseOrder");
|
||||
|
||||
b.Navigation("RaisedByUser");
|
||||
|
||||
b.Navigation("ResolvedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.SupplierClaim", "SupplierClaim")
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("FK_SupplierClaimId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("SupplierClaim");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.UomConversion", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Uom", "FromUom")
|
||||
@@ -2270,12 +2618,22 @@ namespace Distribution.Migrations
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
||||
{
|
||||
b.Navigation("BundleComponents");
|
||||
|
||||
b.Navigation("Redemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class Claim : Template
|
||||
{
|
||||
/// <summary>User holding the RegionalAgency role that this claim is raised against.</summary>
|
||||
public Guid FK_AgencyUserId { get; set; }
|
||||
[ForeignKey("FK_AgencyUserId")]
|
||||
public User AgencyUser { get; set; }
|
||||
|
||||
public string Details { get; set; }
|
||||
public ClaimStatus Status { get; set; } = ClaimStatus.Open;
|
||||
public decimal? SettlementAmount { get; set; }
|
||||
|
||||
public Guid? SettledByUserId { get; set; }
|
||||
[ForeignKey("SettledByUserId")]
|
||||
public User? SettledByUser { get; set; }
|
||||
public DateTime? SettledAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>Audit record of the credit amount netted off an order by a CreditDeduction-mode SaleReturn.</summary>
|
||||
public class CreditNote : Template
|
||||
{
|
||||
public Guid FK_SaleReturnId { get; set; }
|
||||
[ForeignKey("FK_SaleReturnId")]
|
||||
public SaleReturn SaleReturn { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime IssuedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,13 @@
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public enum LocationType
|
||||
{
|
||||
Warehouse,
|
||||
Vehicle
|
||||
}
|
||||
{ Warehouse, Vehicle }
|
||||
|
||||
public enum KycStatus
|
||||
{
|
||||
Pending,
|
||||
Verified,
|
||||
Rejected }
|
||||
public enum KycStatus { Pending,Verified, Rejected }
|
||||
|
||||
public enum MovementType
|
||||
{
|
||||
Sale,
|
||||
CustomerReturn,
|
||||
Transfer,
|
||||
Damage,
|
||||
Adjustment,
|
||||
Load,
|
||||
PurchaseReceipt,
|
||||
PurchaseReturn
|
||||
}
|
||||
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
|
||||
|
||||
public enum LoadingSheetStatus
|
||||
{
|
||||
Pending,
|
||||
DispatchConfirmed,
|
||||
ReceiptConfirmed,
|
||||
DiscrepancyFlagged,
|
||||
Resolved
|
||||
}
|
||||
public enum LoadingSheetStatus { Pending, DispatchConfirmed, ReceiptConfirmed, DiscrepancyFlagged, Resolved }
|
||||
|
||||
public enum RideType { Delivery, Sale }
|
||||
|
||||
@@ -52,10 +28,17 @@ namespace Distribution.Models
|
||||
public enum PaymentMethod { Cash, Credit }
|
||||
|
||||
public enum DeliveryTaskStatus
|
||||
{
|
||||
Pending,
|
||||
Delivered,
|
||||
PartiallyDelivered,
|
||||
Failed
|
||||
}
|
||||
{ Pending, Delivered, PartiallyDelivered, Failed}
|
||||
|
||||
public enum ReturnMode { CreditDeduction, ItemExchange }
|
||||
|
||||
public enum ReturnReasonCode { Expired, Damaged, OrderError, Other }
|
||||
|
||||
public enum ClaimStatus { Open, Settled }
|
||||
|
||||
public enum SupplierClaimStatus { Open, Approved, Rejected }
|
||||
|
||||
public enum SupplierClaimIssueType { Damaged, Short, Expired, WrongItem, Other }
|
||||
|
||||
public enum SupplierClaimResolutionType { Replacement, Credit, Refund, PriceAdjustment }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// A return recorded by a sales rep against an open Order during the same store visit.
|
||||
/// Settled immediately on creation — no supervisor approval step.
|
||||
/// </summary>
|
||||
public class SaleReturn : Template
|
||||
{
|
||||
public Guid FK_OrderId { get; set; }
|
||||
[ForeignKey("FK_OrderId")]
|
||||
public Order Order { get; set; }
|
||||
|
||||
public ReturnMode Mode { get; set; }
|
||||
|
||||
/// <summary>Amount netted off the order's TotalAmount. Zero for ItemExchange mode.</summary>
|
||||
public decimal TotalCreditAmount { get; set; }
|
||||
|
||||
public Guid CreatedByUserId { get; set; }
|
||||
[ForeignKey("CreatedByUserId")]
|
||||
public User CreatedByUser { get; set; }
|
||||
|
||||
public ICollection<SaleReturnItem> Items { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class SaleReturnItem : Template
|
||||
{
|
||||
public Guid FK_SaleReturnId { get; set; }
|
||||
[ForeignKey("FK_SaleReturnId")]
|
||||
public SaleReturn SaleReturn { get; set; }
|
||||
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public ReturnReasonCode ReasonCode { get; set; }
|
||||
|
||||
/// <summary>Snapshot of the item's unit price at return time, used to compute credit amount.</summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class SupplierClaim : Template
|
||||
{
|
||||
public Guid FK_PurchaseOrderId { get; set; }
|
||||
[ForeignKey("FK_PurchaseOrderId")]
|
||||
public PurchaseOrder PurchaseOrder { get; set; }
|
||||
|
||||
public Guid? FK_GoodsReceiptId { get; set; }
|
||||
[ForeignKey("FK_GoodsReceiptId")]
|
||||
public GoodsReceipt? GoodsReceipt { get; set; }
|
||||
|
||||
public string Details { get; set; }
|
||||
public string? EvidenceUrl { get; set; }
|
||||
|
||||
public SupplierClaimStatus Status { get; set; } = SupplierClaimStatus.Open;
|
||||
|
||||
public SupplierClaimResolutionType? ResolutionType { get; set; }
|
||||
public decimal? ResolutionAmount { get; set; }
|
||||
public string? ResolutionNotes { get; set; }
|
||||
|
||||
public Guid RaisedByUserId { get; set; }
|
||||
[ForeignKey("RaisedByUserId")]
|
||||
public User RaisedByUser { get; set; }
|
||||
|
||||
public Guid? ResolvedByUserId { get; set; }
|
||||
[ForeignKey("ResolvedByUserId")]
|
||||
public User? ResolvedByUser { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
|
||||
public ICollection<SupplierClaimItem> Items { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class SupplierClaimItem : Template
|
||||
{
|
||||
public Guid FK_SupplierClaimId { get; set; }
|
||||
[ForeignKey("FK_SupplierClaimId")]
|
||||
public SupplierClaim SupplierClaim { get; set; }
|
||||
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public SupplierClaimIssueType IssueType { get; set; }
|
||||
}
|
||||
}
|
||||
+35
-32
@@ -1,6 +1,6 @@
|
||||
using System.Text;
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.Auth;
|
||||
using Distribution.Middleware;
|
||||
using Distribution.Features.Brand;
|
||||
using Distribution.Features.Category;
|
||||
using Distribution.Features.DeliveryRide;
|
||||
@@ -13,6 +13,9 @@ using Distribution.Features.Order;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.PurchaseReturn;
|
||||
using Distribution.Features.Returns;
|
||||
using Distribution.Features.Claims;
|
||||
using Distribution.Features.SupplierClaims;
|
||||
using Distribution.Features.Region;
|
||||
using Distribution.Features.LoadingEvent;
|
||||
using Distribution.Features.Ride;
|
||||
@@ -31,10 +34,7 @@ using Distribution.Features.Stock;
|
||||
using Distribution.Features.Warehouse;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -73,6 +73,9 @@ builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
|
||||
builder.Services.AddScoped<IPoRepository, PoRepository>();
|
||||
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
|
||||
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
|
||||
builder.Services.AddScoped<IReturnRepository, ReturnRepository>();
|
||||
builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
|
||||
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
|
||||
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
|
||||
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
|
||||
|
||||
@@ -103,43 +106,43 @@ builder.Services.AddScoped<SchemeService>();
|
||||
builder.Services.AddScoped<PoService>();
|
||||
builder.Services.AddScoped<GrnService>();
|
||||
builder.Services.AddScoped<PurchaseReturnService>();
|
||||
builder.Services.AddScoped<ReturnService>();
|
||||
builder.Services.AddScoped<ClaimService>();
|
||||
builder.Services.AddScoped<SupplierClaimService>();
|
||||
builder.Services.AddScoped<DeliveryTaskService>();
|
||||
builder.Services.AddScoped<PaymentService>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(client =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
|
||||
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
||||
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
||||
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
||||
// ── Auth Service connector ───────────────────────────────────────────────────
|
||||
builder.Services.Configure<AuthServiceOptions>(
|
||||
builder.Configuration.GetSection(AuthServiceOptions.SectionName));
|
||||
|
||||
var jwtSection = builder.Configuration.GetSection("Jwt");
|
||||
var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
||||
var authServiceBaseUrl = builder.Configuration[$"{AuthServiceOptions.SectionName}:BaseUrl"];
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtSection["Issuer"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtSection["Audience"],
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
});
|
||||
builder.Services.AddHttpClient<AuthServiceClient>(client =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(authServiceBaseUrl))
|
||||
client.BaseAddress = new Uri(authServiceBaseUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<IAuthServiceClient, AuthServiceClient>();
|
||||
|
||||
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
|
||||
builder.Services.AddCors(options =>
|
||||
options.AddDefaultPolicy(policy =>
|
||||
policy.WithOrigins(allowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()));
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@@ -155,9 +158,9 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
//app.UseHttpsRedirection();
|
||||
app.UseCors();
|
||||
app.UseMiddleware<AuthMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Urls": "http://localhost:5089",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
|
||||
},
|
||||
@@ -9,13 +10,23 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "http://localhost:3000" ]
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "CHANGE_ME_dev_only_replace_with_a_long_random_secret_before_deploying",
|
||||
"Issuer": "Distribution.Api",
|
||||
"Audience": "Distribution.Api",
|
||||
"ExpiryMinutes": 120
|
||||
"PublicKey": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
|
||||
"Audience": "AuthHexClient",
|
||||
"Issuer": "AuthHex"
|
||||
// "ExpiryMinutes": 120
|
||||
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:6102"
|
||||
},
|
||||
"StoreManagement": {
|
||||
"UnverifiedKycOrderThreshold": 0
|
||||
},
|
||||
"AuthService": {
|
||||
"BaseUrl": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 00 — Existing Gap Fixes
|
||||
|
||||
**Status**: Not Started
|
||||
**Status**: Done
|
||||
**Depends On**: —
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 01 — Item Catalog
|
||||
|
||||
**Status**: Ready
|
||||
**Status**: Done
|
||||
**Depends On**: 00 — Existing Gap Fixes (gaps 1 & 2 done)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 04 - Loading Event
|
||||
|
||||
**Status**: Planning
|
||||
**Status**: Done
|
||||
**Depends On**: 03 - Ride Lifecycle
|
||||
|
||||
---
|
||||
|
||||
@@ -64,16 +64,29 @@ When `FulfillImmediately = false`, no stock movement — order stays Pending for
|
||||
|
||||
**Feature folder:** `Features/Payment/`
|
||||
|
||||
### API Endpoints
|
||||
### Sales API Endpoints
|
||||
|
||||
| Method | Route | Who | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
|
||||
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
|
||||
| GET | `/api/sales` | Any | List sales — supports `?rideId=`, `?page=`, `?pageSize=` query params |
|
||||
| POST | `/api/sales/{saleId:guid}/payments` | Driver/Agent | Record a payment against a sale |
|
||||
| GET | `/api/sales/{saleId:guid}/payments` | Any | List all payments for a sale |
|
||||
|
||||
**`GET /api/sales` filter params:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `rideId` | `Guid?` | Filter to sales created on a specific ride |
|
||||
| `page` | `int` | Page number (default 1) |
|
||||
| `pageSize` | `int` | Items per page (default 20) |
|
||||
|
||||
Returns `PagedResponse<SaleResponse>`: `{ page, pageSize, total, items }`.
|
||||
|
||||
Scope rules still apply: non-global users only see sales for stores in their scope.
|
||||
|
||||
### Payment Creation Logic
|
||||
|
||||
On `POST /api/sales/{id}/payments`:
|
||||
On `POST /api/sales/{saleId:guid}/payments`:
|
||||
1. Validate sale exists and is not `Cancelled`.
|
||||
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
|
||||
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
|
||||
|
||||
@@ -100,8 +100,8 @@ One migration needed:
|
||||
### New
|
||||
| Method | Route | Permission | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | /api/stores/{id}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
|
||||
| GET | /api/stores/{id}/kyc-documents | stores.view | Lists documents for a store |
|
||||
| POST | /api/stores/{storeId:guid}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
|
||||
| GET | /api/stores/{storeId:guid}/kyc-documents | stores.view | Lists documents for a store |
|
||||
| PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` |
|
||||
| PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above |
|
||||
| PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit |
|
||||
@@ -115,7 +115,7 @@ One migration needed:
|
||||
|
||||
## Request / Response Shapes
|
||||
|
||||
### POST `/api/stores/{id}/kyc-documents` (multipart/form-data)
|
||||
### POST `/api/stores/{storeId:guid}/kyc-documents` (multipart/form-data)
|
||||
Fields: `documentType` (string), `file` (binary).
|
||||
|
||||
Response:
|
||||
@@ -247,7 +247,7 @@ ever belong to a Store and have no independent lifecycle.
|
||||
|
||||
### KYC upload logic (pseudo)
|
||||
```
|
||||
POST /api/stores/{id}/kyc-documents — multipart (documentType, file):
|
||||
POST /api/stores/{storeId:guid}/kyc-documents — multipart (documentType, file):
|
||||
1. Load Store by id, reject if missing/inactive or caller lacks store scope
|
||||
2. Ensure wwwroot/uploads/kyc/{storeId}/ exists
|
||||
3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}"
|
||||
|
||||
@@ -122,6 +122,15 @@ GoodsReceiptItem
|
||||
```
|
||||
|
||||
### PurchaseReturn
|
||||
|
||||
> **Not the same as `SaleReturn`** ([18_returns_claims.md](./18_returns_claims.md)), despite the
|
||||
> similar name. `PurchaseReturn` moves goods **Distributor → Supplier** (debits the receiving
|
||||
> warehouse); `SaleReturn` moves goods **Shop → Distributor** (credits a delivery vehicle). Also
|
||||
> distinct from `SupplierClaim` ([18_returns_claims.md](./18_returns_claims.md)), which is for
|
||||
> supplier disputes that don't involve physically returning goods (short-shipment, price dispute)
|
||||
> and — unlike this entity — requires approval. See that doc's "Four distinct entities" table for
|
||||
> the full comparison.
|
||||
|
||||
```
|
||||
PurchaseReturn
|
||||
├── Id (Guid) — PK, from Template
|
||||
|
||||
@@ -347,7 +347,7 @@ GET /api/schemes/eligible?storeId=...&orderId=...
|
||||
|
||||
**Apply it:**
|
||||
```
|
||||
POST /api/orders/{orderId}/apply-scheme
|
||||
POST /api/orders/{id}/apply-scheme
|
||||
{ "schemeId": "..." }
|
||||
```
|
||||
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
|
||||
|
||||
@@ -1,74 +1,469 @@
|
||||
# 18 — Returns & Claims Management
|
||||
|
||||
**Status**: Not Started
|
||||
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
|
||||
**Status**: Done — migrations applied to the database. Inspection/classification for customer
|
||||
returns is deferred (see Open Questions).
|
||||
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 15 — Procurement (for
|
||||
`SupplierClaim`)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in
|
||||
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md)
|
||||
both reference this step's credit note and approval shapes — this is where those are actually
|
||||
defined.
|
||||
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) — a customer-side flow where
|
||||
a sales rep records a return during a store visit and a claim workflow against a Regional Agency,
|
||||
plus a supplier-side flow for claims against a `PurchaseOrder`/`GoodsReceipt` that don't involve
|
||||
physically returning goods.
|
||||
|
||||
### Four distinct entities — don't conflate them
|
||||
|
||||
`SaleReturn` and `PurchaseReturn` are both named "...Return" but move goods in opposite
|
||||
directions, and neither is the same as the two `Claim` entities, which never move stock at all:
|
||||
|
||||
| Entity | Direction | Where it lives | Approval? | Stock effect |
|
||||
|---|---|---|---|---|
|
||||
| `SaleReturn` | Shop → Distributor | This step (`Features/Returns`) | No — settles immediately | Credits the ride's **vehicle** |
|
||||
| `Claim` | vs. Regional Agency | This step (`Features/Claims`) | Settle only (`claims.settle`) | None |
|
||||
| `PurchaseReturn` | Distributor → Supplier | [15_procurement.md](./15_procurement.md) (`Features/PurchaseReturn`) | No — settles immediately | Debits the receiving **warehouse** |
|
||||
| `SupplierClaim` | vs. Supplier | This step (`Features/SupplierClaims`) | Yes (`suppliers.claims.approve`) | None — financial/administrative record only |
|
||||
|
||||
`PurchaseReturn` is for physically shipping goods back to a supplier; `SupplierClaim` is for
|
||||
disputes that don't (or can't yet) involve moving goods back — a short-shipment, damage, or a
|
||||
price dispute.
|
||||
|
||||
---
|
||||
|
||||
## Background / Context
|
||||
|
||||
No return, credit note, or claim entity exists today. `createBackorder` (§1,
|
||||
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands.
|
||||
No return, credit note, or claim entity existed before this step. `createBackorder` (§1,
|
||||
[05_delivery_ride.md](./05_delivery_ride.md)) needed a credit note target once this step landed.
|
||||
|
||||
The original spec (mainPlan §12) framed customer returns as an async, supervisor-approved
|
||||
workflow — `createReturnRequest` → `approveReturn` → stock credited only on approval. **That model
|
||||
was dropped during planning.** In practice a sales rep records a return on the spot, during the
|
||||
same store visit as a new order, and it must settle immediately (stock back in the vehicle, the
|
||||
new order's amount adjusted) — there is no point in the field workflow where a return sits
|
||||
"pending" a remote supervisor's sign-off. See Business Rules for what this changes.
|
||||
|
||||
The existing `PurchaseReturn` ([15_procurement.md](./15_procurement.md)) only covers physically
|
||||
returning received goods to a supplier and debiting stock — it has no place for a claim that
|
||||
doesn't (or can't yet) involve moving goods back: a short-shipment (500 ordered, 480 received),
|
||||
goods damaged in transit, or a price dispute. `SupplierClaim` was added alongside `PurchaseReturn`
|
||||
for this, not as a rework of it.
|
||||
|
||||
### Connections to existing modules
|
||||
|
||||
- **Order Management**: a `SaleReturn` is always filed against a specific open `Order`
|
||||
(`Features/Order`) and immediately nets its effect onto that order's `TotalAmount` /
|
||||
`OrderItem.Quantity` — no `Invoice` entity exists yet (Finance, step 19, not built), so there is
|
||||
nothing else for a credit note to net against.
|
||||
- **Stock Ledger** ([02_stock_ledger.md](./02_stock_ledger.md)): `SaleReturn` credits
|
||||
`StockBalance` at the active ride's vehicle (`MovementType.CustomerReturn`), the same enum value
|
||||
`StockService.RecordCustomerReturnAsync` already defined but that no sales-side flow was wiring
|
||||
up until this step. `SupplierClaim` writes no stock movement at all (see Business Rules).
|
||||
- **Ride Execution** ([05_delivery_ride.md](./05_delivery_ride.md)): a `SaleReturn` can only be
|
||||
recorded against an order created during an active ride (`Sale.FK_RideId` must be set), since
|
||||
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`).
|
||||
- **Region Management** ([10_region_management.md](./10_region_management.md)): there is no
|
||||
separate Agency entity in the codebase — "Regional Agency" is a `Role`
|
||||
(`Data/Seed/RoleAndPermissionSeed.cs`), so `Claim` references the `User` holding that role.
|
||||
- **Procurement** ([15_procurement.md](./15_procurement.md)): `SupplierClaim.FK_PurchaseOrderId`
|
||||
is required, `FK_GoodsReceiptId` optional; a short-shipment is already excluded from stock at
|
||||
receipt time (`GoodsReceiptItem.RejectedQuantity`), so `SupplierClaim` never needs to touch
|
||||
`StockBalance` itself.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Return Request
|
||||
Six new tables.
|
||||
|
||||
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`,
|
||||
`Rejected`), `RequestedByUserId`.
|
||||
### SaleReturn
|
||||
```
|
||||
SaleReturn
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── FK_OrderId (Guid) — required, the order this return is filed against
|
||||
├── Mode (enum) — CreditDeduction, ItemExchange — chosen by the rep per return
|
||||
├── TotalCreditAmount (decimal)— sum(Quantity * UnitPrice) for CreditDeduction mode; 0 for ItemExchange
|
||||
├── CreatedByUserId (Guid) — required, the rep who recorded it
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### Credit Note
|
||||
### SaleReturnItem
|
||||
```
|
||||
SaleReturnItem
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── FK_SaleReturnId (Guid) — required
|
||||
├── FK_ItemId (Guid) — required
|
||||
├── Quantity (decimal) — returned quantity, credited back to the ride's vehicle
|
||||
├── ReasonCode (enum) — Expired, Damaged, OrderError, Other
|
||||
├── UnitPrice (decimal) — snapshot at return time (from the matching OrderItem if one
|
||||
│ exists on this order, else Item.SalePrice)
|
||||
```
|
||||
|
||||
`ReturnRequestId`, `InvoiceId`, `Amount`, `IssuedAt`.
|
||||
### CreditNote
|
||||
```
|
||||
CreditNote
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── FK_SaleReturnId (Guid) — required
|
||||
├── Amount (decimal) — amount netted off the order's TotalAmount
|
||||
├── IssuedAt (DateTime)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
Audit-trail only — written for `CreditDeduction`-mode returns, not itself an approval gate or an
|
||||
invoice-linked document (no `Invoice` entity exists yet; see Background/Context).
|
||||
|
||||
### Claim
|
||||
```
|
||||
Claim
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── FK_AgencyUserId (Guid) — required, the User holding the RegionalAgency role this claim is against
|
||||
├── Details (string) — required
|
||||
├── Status (enum) — Open, Settled
|
||||
├── SettlementAmount (decimal?)— set once settled
|
||||
├── SettledByUserId (Guid?), SettledAt (DateTime?)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
Independent of `SaleReturn`/stock — its own open/settle workflow.
|
||||
|
||||
`AgencyId`, `Details`, `Status` (`Open`, `Settled`), `SettlementAmount` (nullable until settled).
|
||||
### SupplierClaim
|
||||
```
|
||||
SupplierClaim
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── FK_PurchaseOrderId (Guid) — required
|
||||
├── FK_GoodsReceiptId (Guid?) — optional; set when the claim stems from a specific receipt
|
||||
│ (e.g. short/damaged at receipt)
|
||||
├── Details (string) — description of the issue
|
||||
├── EvidenceUrl (string?) — link/path to a photo or document; no file-upload storage
|
||||
│ exists yet, so this is a plain string field for now
|
||||
├── Status (enum) — Open, Approved, Rejected
|
||||
├── ResolutionType (enum?) — Replacement, Credit, Refund, PriceAdjustment — set on approve
|
||||
├── ResolutionAmount (decimal?) — for Credit / Refund / PriceAdjustment
|
||||
├── ResolutionNotes (string?)
|
||||
├── RaisedByUserId (Guid)
|
||||
├── ResolvedByUserId (Guid?), ResolvedAt (DateTime?)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### SupplierClaimItem
|
||||
```
|
||||
SupplierClaimItem
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── FK_SupplierClaimId (Guid) — required
|
||||
├── FK_ItemId (Guid) — required
|
||||
├── Quantity (decimal)
|
||||
├── IssueType (enum) — Damaged, Short, Expired, WrongItem, Other
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
Two migrations, applied:
|
||||
1. **AddReturnsAndClaims** (`20260825073453_AddReturnsAndClaims`) — created `SaleReturns`,
|
||||
`SaleReturnItems`, `CreditNotes`, `Claims`, with FKs to `Orders`/`Items`/`Users` and indexes on
|
||||
every FK column.
|
||||
2. **AddSupplierClaims** (`20260825084218_AddSupplierClaims`) — created `SupplierClaims`,
|
||||
`SupplierClaimItems`, with FKs to `PurchaseOrders`/`GoodsReceipts`/`Items`/`Users` and indexes on
|
||||
every FK column.
|
||||
|
||||
Both applied via `dotnet ef database update`.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Function reference | Endpoint intent |
|
||||
> Return routes use bare `[Authorize]` — matches the Supplier/PriceList/Procurement convention (no
|
||||
> per-action policy). `claims.settle` and `suppliers.claims.approve` are the two enforced policies,
|
||||
> since those are the two steps with real approval weight. See Permissions to Add to Seed.
|
||||
|
||||
### Returns (`Features/Returns`)
|
||||
| Function reference | Endpoint |
|
||||
|---|---|
|
||||
| `createReturnRequest(orderId, items[], reasonCode)` | `POST /api/returns` |
|
||||
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` |
|
||||
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval |
|
||||
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
|
||||
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` |
|
||||
| `createSaleReturn(orderId, mode, items[])` | `POST /api/orders/{id}/returns` — creates and immediately settles |
|
||||
| `getSaleReturn(returnId)` | `GET /api/returns/{id}` |
|
||||
| `listSaleReturns(orderId)` | `GET /api/orders/{id}/returns` |
|
||||
|
||||
### Claims (`Features/Claims`)
|
||||
| Function reference | Endpoint |
|
||||
|---|---|
|
||||
| `createClaim(agencyUserId, details)` | `POST /api/claims` |
|
||||
| `getClaim(claimId)` | `GET /api/claims/{id}` |
|
||||
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` — `[Authorize(Policy = "claims.settle")]` |
|
||||
|
||||
### Supplier Claims (`Features/SupplierClaims`)
|
||||
| Function reference | Endpoint |
|
||||
|---|---|
|
||||
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | `POST /api/purchase-orders/{id}/claims` |
|
||||
| `getSupplierClaim(claimId)` | `GET /api/supplier-claims/{id}` |
|
||||
| `listSupplierClaims(poId)` | `GET /api/purchase-orders/{id}/claims` |
|
||||
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | `POST /api/supplier-claims/{id}/approve` — `[Authorize(Policy = "suppliers.claims.approve")]` |
|
||||
| `rejectSupplierClaim(claimId, notes?)` | `POST /api/supplier-claims/{id}/reject` — `[Authorize(Policy = "suppliers.claims.approve")]` |
|
||||
|
||||
`approve`/`reject` combine the "inspect the claim" and "decide the outcome" steps into one call —
|
||||
unlike the customer-side flow there's no separate physical-inspection step to wait on here, since
|
||||
the evidence (photos/notes, and `GoodsReceiptItem.RejectedQuantity` if applicable) is already
|
||||
attached at claim-creation time.
|
||||
|
||||
---
|
||||
|
||||
## Request / Response Shapes
|
||||
|
||||
### POST `/api/orders/{id}/returns`
|
||||
```json
|
||||
{
|
||||
"mode": "CreditDeduction",
|
||||
"items": [{ "itemId": "...", "quantity": 5, "reasonCode": "Expired" }]
|
||||
}
|
||||
```
|
||||
`mode` is `CreditDeduction` or `ItemExchange`, `reasonCode` is `Expired` | `Damaged` |
|
||||
`OrderError` | `Other`.
|
||||
|
||||
### POST `/api/claims`
|
||||
```json
|
||||
{ "agencyUserId": "...", "details": "Short-shipped 20 units on last consignment" }
|
||||
```
|
||||
|
||||
### POST `/api/claims/{id}/settle`
|
||||
```json
|
||||
{ "settlementAmount": 1500.00 }
|
||||
```
|
||||
|
||||
### POST `/api/purchase-orders/{id}/claims`
|
||||
```json
|
||||
{
|
||||
"goodsReceiptId": "...",
|
||||
"details": "20 units arrived damaged, 480 of 500 received",
|
||||
"evidenceUrl": "https://...",
|
||||
"items": [{ "itemId": "...", "quantity": 20, "issueType": "Damaged" }]
|
||||
}
|
||||
```
|
||||
`goodsReceiptId` and `evidenceUrl` are optional. `issueType` is `Damaged` | `Short` | `Expired` |
|
||||
`WrongItem` | `Other`.
|
||||
|
||||
### POST `/api/supplier-claims/{id}/approve`
|
||||
```json
|
||||
{ "resolutionType": "Credit", "resolutionAmount": 850.00, "resolutionNotes": "Agreed with supplier" }
|
||||
```
|
||||
`resolutionType` is `Replacement` | `Credit` | `Refund` | `PriceAdjustment`. `resolutionAmount` is
|
||||
required unless `resolutionType` is `Replacement`.
|
||||
|
||||
### POST `/api/supplier-claims/{id}/reject`
|
||||
```json
|
||||
{ "resolutionNotes": "Discrepancy within accepted tolerance" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
|
||||
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule
|
||||
already stated in [09_fraud_controls.md](./09_fraud_controls.md).
|
||||
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly
|
||||
the `ReturnWithoutSale` anomaly flagged automatically in
|
||||
[09_fraud_controls.md](./09_fraud_controls.md).
|
||||
- Approving a return creates a `Return` stock movement crediting the relevant location, per
|
||||
[02_stock_ledger.md](./02_stock_ledger.md).
|
||||
1. `createSaleReturn` is the entry point — no prior approval step, settlement is immediate. This
|
||||
deliberately supersedes the original spec's `Pending → Approved/Rejected` flow (see
|
||||
Background/Context) since a rep records the return on the spot during a live store visit.
|
||||
2. A return can only be filed against an order that is still open (`OrderStatus.Pending` or
|
||||
`PartiallyFulfilled`) — not `Fulfilled`/`Cancelled` — and whose `Sale.FK_RideId` is set, since
|
||||
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`,
|
||||
`MovementType.CustomerReturn`).
|
||||
3. `createSaleReturn` rejects (409 Conflict) any line item the store has never had on **any** of
|
||||
its past orders — checked across the store's full order history, not just the order the return
|
||||
is filed against. This lets an expired or damaged item bought weeks ago still be returned; it
|
||||
only blocks an item that store never bought at all — the real fraud case originally scoped as
|
||||
`ReturnWithoutSale` in [09_fraud_controls.md](./09_fraud_controls.md), which is not otherwise
|
||||
built.
|
||||
4. `Mode = CreditDeduction`: the returned lines' value (`Quantity * UnitPrice`) is subtracted from
|
||||
`Order.TotalAmount` (floored at 0), and a `CreditNote` audit row is written.
|
||||
5. `Mode = ItemExchange`: the returned quantity is subtracted directly from the matching
|
||||
`OrderItem.Quantity` on the same order (a physical swap of the same item), `TotalAmount`
|
||||
adjusted by the offset amount. No `CreditNote` is written. A returned item with no matching line
|
||||
on the order is credited to stock but does not offset anything on the order.
|
||||
6. Stock credit, the `SaleReturn`/`SaleReturnItem` insert, and the order-total/credit-note update
|
||||
all run inside one `IUnitOfWork` transaction — a failure anywhere rolls back the whole return.
|
||||
7. `settleClaim` can only run once per claim — rejects (409) if already `Settled`. Claims never
|
||||
touch `StockBalance` or any `Order`.
|
||||
8. `createSupplierClaim` requires the referenced `PurchaseOrder` to exist and be active;
|
||||
`FK_GoodsReceiptId`, if given, must belong to that PO (rejects with 409 otherwise).
|
||||
9. Only `Open` supplier claims can be approved or rejected; both are terminal (no re-opening) —
|
||||
enforced by `ApproveAsync`/`RejectAsync` throwing if `Status != Open`.
|
||||
10. `approveSupplierClaim` requires `ResolutionType`; `ResolutionAmount` is required for `Credit`,
|
||||
`Refund`, and `PriceAdjustment` (409 if missing), and is discarded (`null`) for `Replacement`
|
||||
even if supplied.
|
||||
11. Supplier claim approval/rejection is a real sign-off step (unlike the customer-side
|
||||
`SaleReturn`, which settles immediately) — gated behind
|
||||
`[Authorize(Policy = "suppliers.claims.approve")]`, following the Ride cancel-request pattern
|
||||
(`RideController.cs:120-146`).
|
||||
12. No automatic `StockBalance`/`StockMovement` write from any `SupplierClaim` action.
|
||||
`ResolutionType = Replacement` records that new stock is expected; the actual stock credit
|
||||
happens through an ordinary follow-up `GoodsReceipt` once it arrives (see Open Questions).
|
||||
`Credit`/`Refund`/`PriceAdjustment` have no `Invoice`/Finance ledger to post against yet
|
||||
(step 19, not built) — `ResolutionAmount` is captured as an audit field only, the same
|
||||
limitation `CreditNote` has on the customer side.
|
||||
|
||||
---
|
||||
|
||||
## Permissions to Add to Seed
|
||||
|
||||
**Status: `claims.settle` and `suppliers.claims.approve` enforced; return-creation routes
|
||||
intentionally left as bare `[Authorize]`**, matching the Supplier/PriceList/Procurement convention
|
||||
(steps 14/15) rather than introducing enforcement piecemeal. Both enforced policies use the same
|
||||
dynamic policy-provider pattern as `rides.cancel.approve` (`PermissionPolicyProvider`) — no
|
||||
explicit policy registration needed, just a seeded `Permission` row and a `RolePermission` grant.
|
||||
No role assignments seeded yet; `suppliers.claims.approve` likely AreaManager (30) and above,
|
||||
matching the Supplier/Procurement tier.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should return-creation routes eventually get a real permission policy (`returns.create`) once
|
||||
the Supplier/PriceList/Procurement group does, or do they stay bare `[Authorize]` indefinitely
|
||||
since a rep needs to record a return without waiting on a scoped grant?
|
||||
- `ItemExchange` mode clamps the offset to `min(returnedQty, orderItem.Quantity)` — if a rep
|
||||
returns more of an item than is on the current order, the excess is credited to stock but not
|
||||
reflected anywhere on the order. Worth a `CreditNote`-style fallback for the excess once this
|
||||
edge case actually comes up.
|
||||
- No `Invoice` entity exists yet (Finance, step 19) — `CreditNote` and `SupplierClaim`'s
|
||||
`ResolutionAmount` are standalone audit fields for now, not linked to any invoice/ledger
|
||||
document. Revisit once Finance & Accounting is built.
|
||||
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
|
||||
also be raised against an internal warehouse/supplier discrepancy?
|
||||
also be raised against an internal warehouse/supplier discrepancy? Currently only
|
||||
`FK_AgencyUserId` (a User with the RegionalAgency role) is supported.
|
||||
- `SupplierClaim.ResolutionType = Replacement` has no link back to the eventual replacement
|
||||
`GoodsReceipt` — a nullable `FK_SupplierClaimId` on `GoodsReceipt` would close that loop but
|
||||
isn't built yet.
|
||||
- **Inspection & Classification (deferred)**: the fuller process — inspect returned goods,
|
||||
classify each line as Sellable / Damaged / Expired / Quarantine / Disposal, route sellable stock
|
||||
back to available inventory while damaged/expired stock moves to a separate stock category, and
|
||||
only then decide refund vs. replacement vs. credit vs. reject — is explicitly out of scope for
|
||||
now. The current `SaleReturn` flow settles immediately with no inspection step (see
|
||||
Background/Context for why). When it's picked up, it will need: an `InspectionResult` per
|
||||
`SaleReturnItem` recorded after the item is physically back at a warehouse (not at the point of
|
||||
return); a stock-location/category concept for quarantined or condemned stock (today
|
||||
`StockBalance` only distinguishes `LocationType.Warehouse`/`Vehicle`); a real cash-refund concept
|
||||
once Finance/Payments (step 19) exists to reverse a `Payment`; and a decision on whether
|
||||
inspection reopens the immediate-settlement model into a provisional-then-confirmed one — a
|
||||
bigger behavioral change than adding a field, needing its own planning pass first.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
### File structure (as built)
|
||||
```
|
||||
Features/
|
||||
Returns/
|
||||
ReturnController.cs — /api/orders/{id}/returns (POST/GET), /api/returns/{id} (GET)
|
||||
ReturnDTOs.cs — CreateSaleReturnRequest, ReturnItemRequest, SaleReturnResponse(+Item)
|
||||
ReturnService.cs — CreateAsync (validate → credit stock → net order → commit),
|
||||
GetByIdAsync, GetByOrderIdAsync
|
||||
IReturnRepository.cs / ReturnRepository.cs — includes StoreHasPriorSaleAsync
|
||||
Claims/
|
||||
ClaimController.cs — /api/claims (POST/GET), /api/claims/{id}/settle (POST)
|
||||
ClaimDTOs.cs — CreateClaimRequest, SettleClaimRequest, ClaimResponse
|
||||
ClaimService.cs — CreateAsync, SettleAsync, GetByIdAsync, GetByAgencyUserIdAsync
|
||||
IClaimRepository.cs / ClaimRepository.cs
|
||||
SupplierClaims/
|
||||
SupplierClaimController.cs — /api/purchase-orders/{id}/claims (POST/GET),
|
||||
/api/supplier-claims/{id} (GET),
|
||||
/api/supplier-claims/{id}/approve|reject (POST)
|
||||
SupplierClaimDTOs.cs — CreateSupplierClaimRequest, ApproveSupplierClaimRequest,
|
||||
RejectSupplierClaimRequest, SupplierClaimResponse(+Item)
|
||||
SupplierClaimService.cs — CreateAsync, ApproveAsync, RejectAsync, GetByIdAsync,
|
||||
GetByPurchaseOrderIdAsync
|
||||
ISupplierClaimRepository.cs / SupplierClaimRepository.cs
|
||||
|
||||
Models/
|
||||
SaleReturn.cs, SaleReturnItem.cs, CreditNote.cs, Claim.cs (new)
|
||||
SupplierClaim.cs, SupplierClaimItem.cs (new)
|
||||
Enums.cs (extended — ReturnMode, ReturnReasonCode, ClaimStatus, SupplierClaimStatus,
|
||||
SupplierClaimIssueType, SupplierClaimResolutionType added)
|
||||
|
||||
Data/AppDBContext.cs
|
||||
DbSets added for all six entities; FK relationships via Fluent API, DeleteBehavior.Restrict
|
||||
(SupplierClaim.FK_GoodsReceiptId, ResolvedByUserId configured as optional)
|
||||
|
||||
Migrations/
|
||||
20260825073453_AddReturnsAndClaims (applied)
|
||||
20260825084218_AddSupplierClaims (applied)
|
||||
|
||||
Program.cs
|
||||
IReturnRepository/ReturnRepository, IClaimRepository/ClaimRepository,
|
||||
ISupplierClaimRepository/SupplierClaimRepository registered;
|
||||
ReturnService, ClaimService, SupplierClaimService registered
|
||||
```
|
||||
|
||||
### How the models connect
|
||||
```
|
||||
Order (existing) ◄── FK_OrderId ── SaleReturn ── Mode: CreditDeduction | ItemExchange
|
||||
│
|
||||
├─ SaleReturnItem (FK_ItemId, Quantity, ReasonCode, UnitPrice)
|
||||
│ └─► StockBalance/StockMovement (credited: vehicle of
|
||||
│ the order's ride, MovementType.CustomerReturn)
|
||||
│
|
||||
└─ CreditNote (only when Mode = CreditDeduction;
|
||||
Amount = sum(Quantity * UnitPrice))
|
||||
|
||||
Claim ── FK_AgencyUserId ──► User (holding the RegionalAgency role, seeded in
|
||||
Data/Seed/RoleAndPermissionSeed.cs) — no separate Agency entity
|
||||
|
||||
PurchaseOrder (existing, step 15) ◄── FK_PurchaseOrderId ── SupplierClaim
|
||||
│
|
||||
GoodsReceipt (existing, step 15) ◄── FK_GoodsReceiptId (optional) ┘
|
||||
│
|
||||
├─ SupplierClaimItem (FK_ItemId,
|
||||
│ Quantity, IssueType)
|
||||
└─ ResolutionType: Replacement |
|
||||
Credit | Refund | PriceAdjustment
|
||||
(no stock/ledger write — see
|
||||
Business Rules)
|
||||
```
|
||||
|
||||
### Return flow (as implemented)
|
||||
```
|
||||
ReturnService.CreateAsync(orderId, request, createdByUserId):
|
||||
order = load Order, must be Pending/PartiallyFulfilled else throw
|
||||
sale = load order's Sale, must have FK_RideId set else throw
|
||||
ride = load Sale's Ride → vehicleId = ride.FK_VehicleId
|
||||
orderItems = load order's OrderItems (for price snapshot + ItemExchange offset)
|
||||
for each (itemId, qty, reasonCode) in request.Items:
|
||||
guard StoreHasPriorSaleAsync(order.FK_StoreId, itemId) else throw 409
|
||||
unitPrice = matching OrderItem.UnitPrice if present, else Item.SalePrice
|
||||
begin transaction:
|
||||
for each line: credit StockBalance(itemId, vehicleId, Vehicle) += qty
|
||||
add StockMovement(Type = CustomerReturn, ToLocation = vehicle, ReferenceId = return.Id)
|
||||
insert SaleReturn + SaleReturnItems
|
||||
if Mode == CreditDeduction:
|
||||
total = sum(qty * unitPrice); SaleReturn.TotalCreditAmount = total
|
||||
Order.TotalAmount = max(0, Order.TotalAmount - total)
|
||||
insert CreditNote(Amount = total)
|
||||
else (ItemExchange):
|
||||
for each line with a matching OrderItem:
|
||||
offset = min(qty, orderItem.Quantity); orderItem.Quantity -= offset
|
||||
Order.TotalAmount = max(0, Order.TotalAmount - offset * orderItem.UnitPrice)
|
||||
commit
|
||||
```
|
||||
|
||||
### Claim flow (as implemented)
|
||||
```
|
||||
ClaimService.CreateAsync(request): insert Claim(Status = Open)
|
||||
ClaimService.SettleAsync(claimId, settlementAmount, approverId):
|
||||
guard claim exists and Status != Settled else throw
|
||||
Claim.Status = Settled; SettlementAmount = settlementAmount
|
||||
Claim.SettledByUserId = approverId; SettledAt = now
|
||||
```
|
||||
|
||||
### Supplier claim flow (as implemented)
|
||||
```
|
||||
SupplierClaimService.CreateAsync(purchaseOrderId, request, raisedByUserId):
|
||||
po = load PurchaseOrder, must exist and be active else throw
|
||||
guard Details non-empty, Items non-empty
|
||||
if GoodsReceiptId given: guard it exists and belongs to this PO else throw
|
||||
insert SupplierClaim(Status = Open) + SupplierClaimItems
|
||||
|
||||
SupplierClaimService.ApproveAsync(id, request, approverId):
|
||||
guard claim exists and Status == Open else throw
|
||||
guard ResolutionAmount present unless ResolutionType == Replacement else throw
|
||||
Status = Approved; ResolutionType/Amount/Notes set; ResolvedByUserId = approverId; ResolvedAt = now
|
||||
|
||||
SupplierClaimService.RejectAsync(id, request, approverId):
|
||||
guard claim exists and Status == Open else throw
|
||||
Status = Rejected; ResolutionNotes set; ResolvedByUserId = approverId; ResolvedAt = now
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@ home until this step. There is no invoice, payment, or GL entity for warehouse-f
|
||||
| Function reference | Endpoint intent |
|
||||
|---|---|
|
||||
| `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation |
|
||||
| `recordPayment(invoiceId, amount, method)` | `POST /api/invoices/{id}/payments` |
|
||||
| `recordPayment(invoiceId, amount, method)` | Current implemented path is `POST /api/sales/{saleId:guid}/payments` for field-sale collections; `POST /api/invoices/{id}/payments` remains the target endpoint for this future invoice-based finance step |
|
||||
| `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events |
|
||||
| `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` |
|
||||
| `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` |
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Authentication — Own-User Auth via External Identity Service (AuthHex)
|
||||
|
||||
**Status**: Done
|
||||
**Depends On**: — (cross-cutting infrastructure, not a numbered feature step)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Own-user (staff/API) authentication is delegated to an external identity service ("AuthHex"),
|
||||
which owns credentials and signs RS256 JWTs. This app validates those tokens and maps them onto
|
||||
its own `User`/`Role` tables so the existing permission-policy authorization stays unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Background / Context
|
||||
|
||||
Previously this app verified passwords and minted its own HMAC-signed JWT locally. That's been
|
||||
replaced — AuthHex now owns credentials and signing; this app only holds AuthHex's **public** key
|
||||
to validate tokens, and never sees a plaintext password for this flow.
|
||||
|
||||
Authorization is untouched: permission policies, role levels, scope checks, and the seeded role
|
||||
set all still work exactly as before, because every validated token is mapped back onto a local
|
||||
`User`/`Role` row before any authorization check runs.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
```jsonc
|
||||
"Jwt": {
|
||||
"PublicKey": "<RSAKeyValue>...</RSAKeyValue>",
|
||||
"Audience": "AuthHexClient",
|
||||
"Issuer": "AuthHex"
|
||||
},
|
||||
"AuthHex": { "BaseUrl": "http://localhost:6102" }
|
||||
```
|
||||
|
||||
`Jwt:Key` (old symmetric secret) is removed. `Jwt:PublicKey` is AuthHex's RSA public key in XML
|
||||
format (not PEM).
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Route | Notes |
|
||||
|---|---|---|
|
||||
| POST | /api/auth/login | Proxies to AuthHex `loginUser` |
|
||||
| POST | /api/auth/register | Proxies to AuthHex `registerUser` |
|
||||
| GET | /api/auth/me | `[Authorize]`, proxies to AuthHex `getUserDetails` |
|
||||
| POST | /api/auth/logout | `[Authorize]`, proxies to AuthHex `LogoutUser` |
|
||||
|
||||
All four just forward to `POST {AuthHex:BaseUrl}/api/user` and pass AuthHex's response back
|
||||
as-is. No cookie — token returned in the response body, sent back as `Authorization: Bearer`.
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
|
||||
1. No local password check or token issuance — both delegated to AuthHex.
|
||||
2. `AddJwtBearer` validates issuer/audience/signature/lifetime against AuthHex's public key.
|
||||
3. `OnTokenValidated` maps the token's `sub` claim to a local `User` (`IUserRepository`); fails
|
||||
the token if no matching active user exists.
|
||||
4. On success, `username`/`role_id`/`role_level` claims are attached from the **local** `User`/
|
||||
`Role` row — authorization checks downstream are unaffected by anything AuthHex's own token
|
||||
claims contain.
|
||||
5. Only one validation mechanism runs (`AddJwtBearer`) — two ported alternatives exist but are not
|
||||
registered (see Implementation Notes).
|
||||
6. No refresh tokens — once the AuthHex-issued token expires, the client must log in again.
|
||||
|
||||
---
|
||||
|
||||
## Permissions to Add to Seed
|
||||
|
||||
None — this only changes authentication. Permission policies and role seed data are untouched.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Add a refresh-token flow if AuthHex doesn't offer one and re-login UX proves disruptive.
|
||||
- Keep or delete the ported `AuthMiddleware`/`JwtMiddleware` if never actually needed.
|
||||
- No retry/circuit-breaker if AuthHex is unreachable.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
```
|
||||
Features/Auth/ — AuthController, AuthService, AuthDTOs (thin AuthHex proxies)
|
||||
Infra/Authorization/
|
||||
AuthHex/ — IAuthHexClient, AuthHexClient, AuthHexResponse
|
||||
Jwt/RsaXmlKeyReader.cs — portable XML→RSA key parser (RSA.FromXmlString is Windows-only)
|
||||
Middleware/ — AuthMiddleware, JwtMiddleware, JwtOptions, ApiResponse
|
||||
(ported from OSMCore for reference — NOT registered)
|
||||
Program.cs — AddHttpClient<IAuthHexClient>, AddJwtBearer + OnTokenValidated
|
||||
(maps token → local User/Role → role_id/role_level claims)
|
||||
```
|
||||
|
||||
**Gotcha:** don't run more than one of `AddJwtBearer` / `AuthMiddleware` /
|
||||
`JwtMiddleware.CustomMiddlewareAttribute` at once — each re-validates the same token
|
||||
independently.
|
||||
@@ -11,26 +11,32 @@
|
||||
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
|
||||
| Store / Warehouse CRUD | Done | Full API surface with permission checks |
|
||||
| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) |
|
||||
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
|
||||
| Order CRUD | Done | Scope-aware (user only sees their assigned stores); an `Order` is now created only as a side effect of `Sale` creation, not standalone — see §1 Order Management notes below |
|
||||
| Sale & Payment | Done | `Features/Sale` creates a `Sale`+linked `Order` together (pricing, KYC threshold, immediate-handover stock check); `Features/Payment` records collections against a `Sale` and drives its `PaymentStatus` |
|
||||
| Unit of Work + Repositories | Done | Transaction support throughout |
|
||||
| Soft Deletes | Done | IsActive pattern on all entities |
|
||||
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||
| Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) |
|
||||
| Returns & Claims Management | Done | Sale returns, claims, supplier claims; see [18_returns_claims.md](./18_returns_claims.md) |
|
||||
|
||||
## Known Gaps in Existing System (Pre-Work)
|
||||
## Known Gaps in Existing System (Pre-Work) — Resolved
|
||||
|
||||
These are not new features — they are fixable holes in what's already built.
|
||||
They must be resolved before new feature steps begin because later steps depend on correct foundations.
|
||||
Step 00 closed these. Kept here for history; all are fixed in the current codebase.
|
||||
|
||||
| Gap | Impact | Resolution |
|
||||
|---|---|---|
|
||||
| Route DTOs missing RegionId | Can't link routes to regions via API | Fixed — `CreateRouteRequest`/`UpdateRouteRequest`/`RouteResponse` all carry `RegionId` (`Features/Route/RouteDTOs.cs`). |
|
||||
| Store DTOs missing RouteId | Can't link stores to routes via API | Fixed — all Store DTOs carry `RouteId` (`Features/Store/StoreDTOs.cs`). |
|
||||
| Role endpoints have no [Authorize] | Anyone can read roles without authentication | Fixed — `[Authorize]` on `RoleController` (`Features/Role/RoleController.cs`). |
|
||||
| Item model is empty (no properties) | OrderItems reference items that have no data | Fixed — `Item` has `Sku`/`Name`/`Description`/category/brand/UOM/pricing fields (`Models/Item.cs`). |
|
||||
|
||||
### Still open (not a step-00 blocker, tracked separately)
|
||||
|
||||
| Gap | Impact |
|
||||
|---|---|
|
||||
| Route DTOs missing RegionId | Can't link routes to regions via API |
|
||||
| Store DTOs missing RouteId | Can't link stores to routes via API |
|
||||
| Role endpoints have no [Authorize] | Anyone can read roles without authentication |
|
||||
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment |
|
||||
| No input validation on any DTO | Bad data can enter the system silently |
|
||||
| Item model is empty (no properties) | OrderItems reference items that have no data |
|
||||
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment — `appsettings.json` still has a literal DB password and placeholder JWT key. |
|
||||
| No input validation on any DTO | Bad data can enter the system silently. |
|
||||
|
||||
---
|
||||
|
||||
@@ -56,7 +62,7 @@ They must be resolved before new feature steps begin because later steps depend
|
||||
| 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
||||
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
||||
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
|
||||
| 18 | Returns & Claims Management | Done | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
|
||||
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
|
||||
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
|
||||
| 21 | Notifications & Alerts | Not Started | 02, 12, 08 | [21_notifications_alerts.md](./21_notifications_alerts.md) |
|
||||
@@ -81,18 +87,27 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|
||||
### 1. Order Management
|
||||
|
||||
> **Design change since this section was first written**: `Order` is no longer created directly
|
||||
> from `storeId/items/salesRepId`. Order creation was folded into `Features/Sale` — every `Order`
|
||||
> is now a 1:1 side effect of `SaleService.CreateAsync` (see §6 Sale Ride Execution). `POST
|
||||
> /api/orders` (`OrderService.CreateAsync`) is now just a lookup — it throws
|
||||
> `"Orders are created automatically from sales"` if called for a sale that doesn't already have
|
||||
> one, rather than creating a standalone order. All the validation described below (stock check,
|
||||
> KYC threshold, pricing) now happens in `SaleService.CreateAsync`
|
||||
> (`Features/Sale/SaleService.cs`), not `OrderService`.
|
||||
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, applies the unverified-KYC order threshold from config, and now checks stock availability (see `checkStockAvailability` below). It still has no credit-limit validation, approval flow, or order state machine. |
|
||||
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. |
|
||||
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Implemented — `OrderService.CreateAsync` rejects an order if any line's requested quantity exceeds total `StockBalance` for that item summed across every location (added alongside [15_procurement.md](./15_procurement.md)). Not location-specific yet (no fulfilling-warehouse concept on `Store`/`Order`), and does not reserve or decrement stock — that stays deferred to loading/delivery (§05-06). |
|
||||
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`). The scheme-discount half now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. |
|
||||
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Superseded — see design-change note above. The equivalent entry point is `SaleService.CreateAsync` (`createSale`, §6), which creates the `Sale` and its linked `Order` together. `OrderService.CreateAsync` itself only fetches the existing order for a given `SaleId`. |
|
||||
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or sale/order-time credit-limit enforcement yet. |
|
||||
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Partially implemented — `SaleService.CreateAsync` checks `StockBalance` at the ride's vehicle, but **only** when `FulfillImmediately = true` and a `RideId` is supplied (immediate van-sale handover); a deferred/pending sale does no stock check at create time at all. Not warehouse-wide, not reserved — a failed check simply returns `null` (400) rather than a typed error. |
|
||||
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `SaleService.CreateAsync` resolves the price-list half via `ResolveUnitPriceAsync` → `PriceListService.ResolvePriceAsync` (region-scoped via the store's route → `Item.SalePrice` fallback, or a caller-supplied `UnitPrice` validated against `[MinPrice, Price]`). The scheme-discount half is a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — not auto-invoked from `createSale`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after the order exists. |
|
||||
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
||||
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
||||
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
|
||||
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Implemented, with a smaller state machine than described — `Models/Enums.cs` defines `OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }`; `OrderService.UpdateAsync` (`PUT /api/orders/{id}`) sets `Order.Status` directly (any status accepted, no transition guard) and mirrors it onto the linked `Sale.Status`/`CompletedAt`. No `approved/picked/dispatched/invoiced` states exist. |
|
||||
| `convertOrderToInvoice(orderId)` | Generates the invoice once the order is dispatched/delivered. | Not implemented — depends on §13 Finance & Accounting. |
|
||||
| `createBackorder(orderId, unfulfilledItems[])` | Splits unfulfillable quantity into a linked backorder. | Not implemented. |
|
||||
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Not implemented — `OrderController` supports soft delete only, no reason code or stock release. |
|
||||
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Partially implemented — `OrderService.DeleteAsync` (`DELETE /api/orders/{id}`) soft-deletes the order and cascades `Sale.Status = Cancelled`, but takes no reason code and releases no reserved stock (nothing is reserved at create time to release). |
|
||||
| `getOrdersByStore(storeId)` / `getOrdersBySalesRep(salesRepId)` | Scoped order listings. | `getOrdersByStore` implemented (`GET /api/orders/store/{storeId}`). `getOrdersBySalesRep` not implemented. |
|
||||
| `getOrderAnalytics(filters)` | Returns fill rate, average order value, rejection rate over a period. | Not implemented. |
|
||||
|
||||
@@ -128,9 +143,12 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
|
||||
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{storeId:guid}/kyc-documents` endpoints rather than inline on create. |
|
||||
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{storeId:guid}/kyc-documents`. |
|
||||
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{storeId:guid}/kyc-documents`. |
|
||||
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
|
||||
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
|
||||
| `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. |
|
||||
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
|
||||
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
|
||||
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
||||
@@ -177,9 +195,18 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|---|---|---|
|
||||
| `createProduct(skuData)` | Creates a SKU with category, UOM, barcode, pack hierarchy. | Not implemented — `Item` model exists but has no properties; see [01_item_catalog.md](./01_item_catalog.md). |
|
||||
| `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
|
||||
| `listPriceLists(regionId?)` | Lists price lists, optionally filtered by region. | Implemented — `GET /api/price-lists`. |
|
||||
| `getPriceList(priceListId)` | Fetches a price list and its items. | Implemented — `GET /api/price-lists/{id}`. |
|
||||
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Implemented — `POST /api/price-lists`, see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md). |
|
||||
| `updateProductPricing(sku, priceListId, price)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
|
||||
| `updatePriceList(priceListId, name, region, effectiveDates)` | Updates price-list metadata. | Implemented — `PUT /api/price-lists/{id}`. |
|
||||
| `deletePriceList(priceListId)` | Soft-deletes a price list. | Implemented — `DELETE /api/price-lists/{id}`. |
|
||||
| `updateProductPricing(sku, priceListId, price, minPrice?)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
|
||||
| `removeProductPricing(sku, priceListId)` | Removes a SKU override from a price list. | Implemented — `DELETE /api/price-lists/{id}/items/{sku}`. |
|
||||
| `listSuppliers()` | Lists suppliers. | Implemented — `GET /api/suppliers`. |
|
||||
| `getSupplier(supplierId)` | Fetches a supplier by ID. | Implemented — `GET /api/suppliers/{id}`. |
|
||||
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Implemented — `POST /api/suppliers`. `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` exists. |
|
||||
| `updateSupplier(supplierId, supplierData)` | Updates supplier master data. | Implemented — `PUT /api/suppliers/{id}`. `PerformanceScore` remains read-only here. |
|
||||
| `deleteSupplier(supplierId)` | Soft-deletes a supplier. | Implemented — `DELETE /api/suppliers/{id}`. Blocked if an active PurchaseOrder references the supplier. |
|
||||
|
||||
### 8. Enterprise-wide Inventory & Stock
|
||||
|
||||
@@ -197,9 +224,21 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|---|---|---|
|
||||
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. |
|
||||
| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. |
|
||||
| `getPurchaseOrder(poId)` | Fetches a purchase order. | Implemented — `GET /api/purchase-orders/{id}`. |
|
||||
| `listPurchaseOrders(filters)` | Lists purchase orders. | Implemented — `GET /api/purchase-orders`. |
|
||||
| `updatePurchaseOrder(poId, items[])` | Updates an open purchase order. | Implemented — `PUT /api/purchase-orders/{id}`. |
|
||||
| `closePurchaseOrder(poId)` | Closes a received purchase order. | Implemented — `POST /api/purchase-orders/{id}/close`. |
|
||||
| `cancelPurchaseOrder(poId, reason)` | Cancels a purchase order before receipts exist. | Implemented — `POST /api/purchase-orders/{id}/cancel`. |
|
||||
| `cancelPurchaseOrderLine(poId, poItemId, reason)` | Cancels a single PO line. | Implemented — `POST /api/purchase-orders/{id}/items/{itemId}/cancel`. |
|
||||
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. |
|
||||
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
|
||||
| `resolveInvoiceVariance(poId, variance, resolution)` | Records how a mismatch should be handled. | Implemented — `POST /api/purchase-orders/{id}/resolve-variance`. |
|
||||
| `getGoodsReceipt(grnId)` | Fetches a goods receipt. | Implemented — `GET /api/goods-receipts/{id}`. |
|
||||
| `listGoodsReceipts(poId)` | Lists goods receipts for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/goods-receipts`. |
|
||||
| `createGoodsReceipt(poId, warehouseId, items[])` | Records inbound stock against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/goods-receipts`. |
|
||||
| `getPurchaseReturn(returnId)` | Fetches a purchase return. | Implemented — `GET /api/purchase-returns/{id}`. |
|
||||
| `listPurchaseReturns(poId)` | Lists purchase returns for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/returns`. |
|
||||
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. |
|
||||
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
|
||||
|
||||
### 10. Sales Force Automation (SFA) / CRM
|
||||
|
||||
@@ -225,19 +264,25 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createReturnRequest(orderId, items[], reasonCode)` | Initiates a return against a delivered order. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) for the fraud-control angle on returns. |
|
||||
| `approveReturn(returnId, approverId)` | Approves/rejects the return request. | Not implemented. |
|
||||
| `generateCreditNote(returnId)` | Issues a credit note against the original invoice. | Not implemented. |
|
||||
| `createClaim(agencyId, claimDetails)` | Raises a claim against a Regional Agency (e.g., damaged stock). | Not implemented. |
|
||||
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Not implemented. |
|
||||
| `createSaleReturn(orderId, mode, items[])` | Records a customer return against a delivered order. | Implemented — `POST /api/orders/{id}/returns`, settles immediately and credits the ride's vehicle. No approval step. |
|
||||
| `getSaleReturn(returnId)` | Fetches a sale return by ID. | Implemented — `GET /api/returns/{id}`. |
|
||||
| `listSaleReturns(orderId)` | Lists sale returns for an order. | Implemented — `GET /api/orders/{id}/returns`. |
|
||||
| `createClaim(agencyUserId, details)` | Raises a claim against a Regional Agency user. | Implemented — `POST /api/claims`. |
|
||||
| `getClaim(claimId)` | Fetches a claim by ID. | Implemented — `GET /api/claims/{id}`. |
|
||||
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Implemented — `POST /api/claims/{id}/settle`, requires `claims.settle`. |
|
||||
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | Raises a supplier claim against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/claims`. |
|
||||
| `getSupplierClaim(claimId)` | Fetches a supplier claim by ID. | Implemented — `GET /api/supplier-claims/{id}`. |
|
||||
| `listSupplierClaims(poId)` | Lists supplier claims for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/claims`. |
|
||||
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | Approves a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/approve`, requires `suppliers.claims.approve`. |
|
||||
| `rejectSupplierClaim(claimId, notes?)` | Rejects a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/reject`, requires `suppliers.claims.approve`. |
|
||||
|
||||
### 13. Finance & Accounting
|
||||
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no invoice entity exists. |
|
||||
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Not implemented. |
|
||||
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented. |
|
||||
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. |
|
||||
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId:guid}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
|
||||
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. |
|
||||
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
|
||||
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
|
||||
|
||||
@@ -359,8 +404,9 @@ and approval chains for exceptions. Supervisors can grant one-time overrides log
|
||||
A dedicated audit log records every sensitive event (who, what, when, from where).
|
||||
Automated checks flag: returns without a matching prior sale, sales outside the geofenced route,
|
||||
agents with consistently high variance rates, and cash collected below invoiced amounts.
|
||||
Return approval requires supervisor sign-off before stock is credited. Anomaly reports are
|
||||
surfaced to area managers on a scheduled basis.
|
||||
Return-related processing is handled in §12 as an immediate-settlement flow; this step focuses on
|
||||
audit logging and anomaly detection around those events. Anomaly reports are surfaced to area
|
||||
managers on a scheduled basis.
|
||||
**Gate**: Every sensitive event is logged immutably. Automated flags surface to the right level of management.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
name: shadcn
|
||||
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI, including chat interfaces. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
# shadcn/ui
|
||||
|
||||
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||
|
||||
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
## Current Project Context
|
||||
|
||||
```json
|
||||
!`npx shadcn@latest info --json`
|
||||
```
|
||||
|
||||
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||
|
||||
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||
|
||||
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||
|
||||
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||
|
||||
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||
|
||||
### Component Structure → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||
|
||||
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||
- **Toast follows the project base.** Use `toast` from the `toast` component for
|
||||
Base UI projects. Use `toast()` from `sonner` for Radix and React Aria
|
||||
projects.
|
||||
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||
- **Use `Badge`** instead of custom styled spans.
|
||||
|
||||
### Icons → [icons.md](./rules/icons.md)
|
||||
|
||||
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||
|
||||
### Chat & Messaging → [chat.md](./rules/chat.md)
|
||||
|
||||
- **Chat UI composes the chat primitives.** Conversations use `MessageScroller`, rows use `Message`, surfaces use `Bubble`. Never hand-rolled bubble `div`s or a raw scroll container.
|
||||
- **`MessageScroller` owns scroll behavior.** Streaming follow, anchoring, and jump-to-latest (`MessageScrollerButton`) are built in. Don't write a `useStickToBottom`/`ResizeObserver` hook.
|
||||
- **Attachments use `Attachment`; system notes and dividers use `Marker`.** Not `Item` cards or `Separator` + a label.
|
||||
|
||||
### CLI
|
||||
|
||||
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||
|
||||
```tsx
|
||||
// Form layout: FieldGroup + Field, not div + Label.
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||
<Field data-invalid>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input aria-invalid />
|
||||
<FieldDescription>Invalid email.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Icons in buttons: data-icon, no sizing classes.
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
// Spacing: gap-*, not space-y-*.
|
||||
<div className="flex flex-col gap-4"> // correct
|
||||
<div className="space-y-4"> // wrong
|
||||
|
||||
// Equal dimensions: size-*, not w-* h-*.
|
||||
<Avatar className="size-10"> // correct
|
||||
<Avatar className="w-10 h-10"> // wrong
|
||||
|
||||
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||
```
|
||||
|
||||
## Component Selection
|
||||
|
||||
| Need | Use |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Button/action | `Button` with appropriate variant |
|
||||
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||
| Feedback | `toast` (Base UI), `sonner` (Radix/Aria), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||
| Command palette | `Command` inside `Dialog` |
|
||||
| Charts | `Chart` (wraps Recharts) |
|
||||
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||
| Empty states | `Empty` |
|
||||
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||
| Chat / conversation UI | `MessageScroller`, `Message`, `Bubble`, `Attachment`, `Marker` |
|
||||
|
||||
## Key Fields
|
||||
|
||||
The injected project context contains these key fields:
|
||||
|
||||
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||
|
||||
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||
|
||||
## Component Docs, Examples, and Usage
|
||||
|
||||
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs button dialog select
|
||||
```
|
||||
|
||||
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||
3. **Find components** — `npx shadcn@latest search`.
|
||||
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
|
||||
## Updating Components
|
||||
|
||||
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||
|
||||
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||
3. Decide per file based on the diff:
|
||||
- No local changes → safe to overwrite.
|
||||
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Create a new project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova
|
||||
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||
|
||||
# Create a monorepo project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||
|
||||
# Initialize existing project.
|
||||
npx shadcn@latest init --preset base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||
|
||||
# Apply a preset to an existing project.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
npx shadcn@latest apply a2r6bw --only theme
|
||||
npx shadcn@latest apply a2r6bw --only font
|
||||
npx shadcn@latest apply a2r6bw --only theme,font
|
||||
|
||||
# Inspect preset codes and project preset state.
|
||||
npx shadcn@latest preset decode a2r6bw
|
||||
npx shadcn@latest preset url a2r6bw
|
||||
npx shadcn@latest preset open a2r6bw
|
||||
npx shadcn@latest preset resolve
|
||||
npx shadcn@latest preset resolve --json
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
npx shadcn@latest add @magicui/shimmer-button
|
||||
npx shadcn@latest add owner/repo/item
|
||||
npx shadcn@latest add --all
|
||||
|
||||
# Preview changes before adding/updating.
|
||||
npx shadcn@latest add button --dry-run
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
npx shadcn@latest add @acme/form --view button.tsx
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# Search registries.
|
||||
npx shadcn@latest search @shadcn -q "sidebar"
|
||||
npx shadcn@latest search @tailark -q "stats"
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
npx shadcn@latest search # all configured registries
|
||||
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
|
||||
|
||||
# Get component docs and example URLs.
|
||||
npx shadcn@latest docs button dialog select
|
||||
|
||||
# View registry item details (for items not yet installed).
|
||||
npx shadcn@latest view @shadcn/button
|
||||
npx shadcn@latest view owner/repo/item
|
||||
```
|
||||
|
||||
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||
|
||||
## Detailed References
|
||||
|
||||
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||
- [rules/chat.md](./rules/chat.md) — MessageScroller, Message, Bubble, Attachment, Marker; streaming, anchoring, jump-to-latest
|
||||
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
|
||||
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||
@@ -0,0 +1,5 @@
|
||||
interface:
|
||||
display_name: "shadcn/ui"
|
||||
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||
icon_small: "./assets/shadcn-small.png"
|
||||
icon_large: "./assets/shadcn.png"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,290 @@
|
||||
# shadcn CLI Reference
|
||||
|
||||
Configuration is read from `components.json`.
|
||||
|
||||
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||
|
||||
## Contents
|
||||
|
||||
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||
- Templates: next, vite, start, react-router, astro
|
||||
- Presets: named, code, URL formats and fields
|
||||
- Switching presets
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### `init` — Initialize or create a project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init [components...] [options]
|
||||
```
|
||||
|
||||
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--name <name>` | `-n` | Name for new project | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--rtl` | | Enable RTL support | — |
|
||||
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||
|
||||
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||
|
||||
### `apply` — Apply a preset to an existing project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply [preset] [options]
|
||||
```
|
||||
|
||||
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
|
||||
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||
|
||||
### `add` — Add components
|
||||
|
||||
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add [components...] [options]
|
||||
```
|
||||
|
||||
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
|
||||
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--all` | `-a` | Add all available components | `false` |
|
||||
| `--path <path>` | `-p` | Target path for the component | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
|
||||
#### Dry-Run Mode
|
||||
|
||||
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||
|
||||
```bash
|
||||
# Preview all changes.
|
||||
npx shadcn@latest add button --dry-run
|
||||
|
||||
# Show diffs for all files (top 5).
|
||||
npx shadcn@latest add button --diff
|
||||
|
||||
# Show the diff for a specific file.
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
|
||||
# Show contents for all files (top 5).
|
||||
npx shadcn@latest add button --view
|
||||
|
||||
# Show the full content of a specific file.
|
||||
npx shadcn@latest add button --view button.tsx
|
||||
|
||||
# Works with URLs too.
|
||||
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||
|
||||
# Works with public GitHub registries too.
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# CSS diffs.
|
||||
npx shadcn@latest add button --diff globals.css
|
||||
```
|
||||
|
||||
**When to use dry-run:**
|
||||
|
||||
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||
- When the user wants to inspect component source code without installing — use `--view`.
|
||||
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||
|
||||
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||
|
||||
#### Smart Merge from Upstream
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||
|
||||
### `search` — Search registries
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search [registries...] [options]
|
||||
```
|
||||
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
||||
and registry catalog URLs. Without `-q`, lists all items. When no registries are
|
||||
passed, searches every registry configured in `components.json`.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------------- | ------- |
|
||||
| `--query <query>` | `-q` | Search query | — |
|
||||
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
|
||||
| `--limit <number>` | `-l` | Max items to display | `100` |
|
||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||
| `--json` | | Output as JSON | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
### `view` — View item details
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view <items...> [options]
|
||||
```
|
||||
|
||||
Displays item info including file contents. Examples:
|
||||
`npx shadcn@latest view @shadcn/button`,
|
||||
`npx shadcn@latest view owner/repo/item`.
|
||||
|
||||
### `docs` — Get component documentation URLs
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs <components...> [options]
|
||||
```
|
||||
|
||||
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||
|
||||
Example output for `npx shadcn@latest docs input button`:
|
||||
|
||||
```
|
||||
base radix
|
||||
|
||||
input
|
||||
docs https://ui.shadcn.com/docs/components/radix/input
|
||||
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||
|
||||
button
|
||||
docs https://ui.shadcn.com/docs/components/radix/button
|
||||
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||
```
|
||||
|
||||
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||
|
||||
### `diff` — Check for updates
|
||||
|
||||
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||
|
||||
### `info` — Project information
|
||||
|
||||
```bash
|
||||
npx shadcn@latest info [options]
|
||||
```
|
||||
|
||||
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------- | ----- | ----------------- | ------- |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
**Project Info fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||
|
||||
**Components.json fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||
| `rsc` | `boolean` | RSC flag from config |
|
||||
| `tsx` | `boolean` | TypeScript flag |
|
||||
| `tailwind.config` | `string` | Tailwind config path |
|
||||
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||
| `registries` | `object` | Configured custom registries |
|
||||
|
||||
**Links fields:**
|
||||
|
||||
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||
|
||||
### `build` — Build a custom registry
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build [registry] [options]
|
||||
```
|
||||
|
||||
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||
|
||||
For authoring rules, `include`, item definitions, `registryDependencies`, and
|
||||
GitHub registry behavior, see [registry.md](./registry.md).
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------- | ----- | ----------------- | ------------ |
|
||||
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
---
|
||||
|
||||
## Templates
|
||||
|
||||
| Value | Framework | Monorepo support |
|
||||
| -------------- | -------------- | ---------------- |
|
||||
| `next` | Next.js | Yes |
|
||||
| `vite` | Vite | Yes |
|
||||
| `start` | TanStack Start | Yes |
|
||||
| `react-router` | React Router | Yes |
|
||||
| `astro` | Astro | Yes |
|
||||
| `laravel` | Laravel | No |
|
||||
|
||||
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||
|
||||
---
|
||||
|
||||
## Presets
|
||||
|
||||
Three ways to specify a preset via `--preset`:
|
||||
|
||||
1. **Named:** `--preset nova` or `--preset lyra`
|
||||
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||
|
||||
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||
|
||||
## Switching Presets
|
||||
|
||||
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||
|
||||
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||
|
||||
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user