Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 932d09c135 | |||
| 455ce699ad | |||
| 7d7e9f1318 | |||
| 87e3112a9a | |||
| 801276559d | |||
| b1bda1448e | |||
| 58247a1a0b | |||
| 3a503abe6a | |||
| 855eb44118 | |||
| 33dc4adce2 | |||
| 5b2b39b7bf | |||
| 2c39b34c5f | |||
| 99832df134 | |||
| 2be9d9b396 | |||
| a12e170205 | |||
| fdfca7cf5e | |||
| e84ce29064 | |||
| 2be2928ad1 | |||
| 872fd42c4a | |||
| d79c5e55d0 | |||
| 50d3113c28 | |||
| 34d33571de | |||
| 0f575370d4 | |||
| e4061fa94c | |||
| 5f8bc70174 | |||
| f2d5c9f50a | |||
| 9e39981c9a | |||
| bd8b2d8b44 | |||
| f26557a66e | |||
| 91959913a8 | |||
| 48f607d623 | |||
| 82be251878 | |||
| 209ee7da2d | |||
| 6b9f27573b | |||
| a0df2a1bbf | |||
| d1c3d4e22f | |||
| a80512994b | |||
| 2de9e5e4ad | |||
| 5707bd4c31 | |||
| 28c465f59b | |||
| 21b38b5276 | |||
| 992cd292fa | |||
| 7b6c9ac92a | |||
| eb0b042f0c | |||
| a980e636f5 | |||
| b992d07689 | |||
| a4d58f3377 | |||
| 94db2a311c | |||
| 6b608ad2ef | |||
| 87c807c16c | |||
| 3c75f9786e | |||
| 2d6723a040 | |||
| 2fe2ba007c | |||
| f3f2b71bd4 | |||
| bc72426495 | |||
| da29492798 | |||
| ce21aa6588 | |||
| f44cbf711b | |||
| aaac42b481 | |||
| 7a21cde217 | |||
| 531b3a7fef | |||
| a81e32bbe2 | |||
| 4ef63420b7 | |||
| 9fdf5aa8e3 | |||
| d9c339949e | |||
| 0052725b88 | |||
| 5d18aba1f9 | |||
| 5828879ddc | |||
| 728f2a8b17 | |||
| 1b66d5d767 | |||
| 5c37f02f19 | |||
| 6b3f28d3ae | |||
| 558afc6f9d | |||
| 5b2ac8cc14 | |||
| 5cb32bee95 | |||
| 9adfff48a7 | |||
| f5967497be | |||
| 74f33d8979 | |||
| 4110e2fcfc | |||
| 23fb7361a4 | |||
| c52eaabec4 | |||
| 63c1c4ad1a | |||
| df0eb3cd5a | |||
| cc72e22664 | |||
| 37f4a88014 | |||
| a4f5d12234 | |||
| 2d365cd906 | |||
| d6fd685b3c | |||
| c68c6e1ab9 | |||
| aaf6e5f12d | |||
| ddc3ecd3e6 | |||
| 556dedb8a3 | |||
| d7b6f29200 | |||
| 6f72f7a0e9 | |||
| 53dcdf929a | |||
| d2277f626c | |||
| 3149902574 | |||
| 37fb72376a | |||
| 52a9a3fde7 | |||
| 1b310c86b3 | |||
| e398ee9d6b | |||
| a3c0211654 | |||
| e4e12aa05a | |||
| 3103fb66ef | |||
| 93355d34f1 | |||
| 9151f47d15 | |||
| 661b522b7d | |||
| e0bb17d0ff | |||
| 0f29413375 | |||
| ef17f7da0a | |||
| d9031f8e73 | |||
| 75578ea7f6 | |||
| 4f02fa303d |
@@ -0,0 +1,209 @@
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Distribution.Tests.Services;
|
||||
|
||||
public class NotificationServiceTests
|
||||
{
|
||||
private readonly Mock<INotificationRepository> _repo = new();
|
||||
private readonly Mock<IUnitOfWork> _uow = new();
|
||||
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
|
||||
private readonly Mock<IHubClients> _clients = new();
|
||||
private readonly Mock<IClientProxy> _proxy = new();
|
||||
private readonly NotificationService _sut;
|
||||
|
||||
public NotificationServiceTests()
|
||||
{
|
||||
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
|
||||
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
|
||||
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
|
||||
|
||||
_sut = new NotificationService(
|
||||
_repo.Object,
|
||||
new NotificationRecipientResolver(_repo.Object),
|
||||
_hub.Object,
|
||||
_uow.Object,
|
||||
NullLogger<NotificationService>.Instance);
|
||||
}
|
||||
|
||||
private static NotificationArgs Args(Guid? actor = null) =>
|
||||
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
|
||||
|
||||
// Registry
|
||||
|
||||
[Fact]
|
||||
public void EveryNotificationTypeConstant_HasADefinition()
|
||||
{
|
||||
var constants = typeof(NotificationTypes)
|
||||
.GetFields()
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(constants);
|
||||
Assert.Equal(constants.Count, constants.Distinct().Count());
|
||||
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
|
||||
}
|
||||
|
||||
// NotifyAsync
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
|
||||
{
|
||||
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
|
||||
|
||||
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
|
||||
{
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
|
||||
|
||||
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_ExcludesTheActingUser()
|
||||
{
|
||||
var actor = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||
[RecipientRule.Users(actor, other)], Args(actor));
|
||||
|
||||
Assert.NotNull(saved);
|
||||
var recipient = Assert.Single(saved!.Recipients);
|
||||
Assert.Equal(other, recipient.FK_UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
|
||||
{
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
|
||||
|
||||
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_clients.Verify(c => c.User(a.ToString()), Times.Once);
|
||||
_clients.Verify(c => c.User(b.ToString()), Times.Once);
|
||||
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
|
||||
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
|
||||
{
|
||||
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
|
||||
|
||||
var ex = await Record.ExceptionAsync(() =>
|
||||
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||
|
||||
Assert.Null(ex);
|
||||
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
|
||||
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
|
||||
{
|
||||
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("socket gone"));
|
||||
|
||||
var ex = await Record.ExceptionAsync(() =>
|
||||
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||
|
||||
Assert.Null(ex);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
|
||||
}
|
||||
|
||||
// Recipient rules
|
||||
|
||||
[Fact]
|
||||
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
|
||||
{
|
||||
var both = Guid.NewGuid();
|
||||
var permissionOnly = Guid.NewGuid();
|
||||
var scopeOnly = Guid.NewGuid();
|
||||
var storeId = Guid.NewGuid();
|
||||
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
|
||||
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
|
||||
|
||||
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
|
||||
|
||||
Assert.Equal([both], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
var supervisor = Guid.NewGuid();
|
||||
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||
|
||||
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
|
||||
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||
|
||||
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
|
||||
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
|
||||
{
|
||||
var storeId = Guid.NewGuid();
|
||||
var owner = Guid.NewGuid();
|
||||
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||
|
||||
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
|
||||
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||
|
||||
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
|
||||
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
|
||||
{
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
|
||||
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
|
||||
|
||||
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_DeduplicatesUsersAcrossRules()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
|
||||
|
||||
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
|
||||
|
||||
Assert.Equal([user], ids);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||
public DbSet<Invoice> Invoices { get; set; }
|
||||
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||||
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
|
||||
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
|
||||
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||
public DbSet<User> Users { get; set; }
|
||||
@@ -65,6 +68,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||
public DbSet<SysConfig> SysConfigs { get; set; }
|
||||
public DbSet<BankAccount> BankAccounts { get; set; }
|
||||
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
||||
public DbSet<ChequePage> ChequePages { get; set; }
|
||||
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
||||
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
||||
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
||||
public DbSet<Notification> Notifications { get; set; }
|
||||
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
|
||||
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -166,6 +178,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(i => i.FK_ShopPOId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.Invoice)
|
||||
.WithMany(inv => inv.Items)
|
||||
.HasForeignKey(i => i.FK_InvoiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.ShopPOItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersion>()
|
||||
.HasOne(v => v.Invoice)
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.FK_InvoiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersion>()
|
||||
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.InvoiceVersion)
|
||||
.WithMany(v => v.Items)
|
||||
.HasForeignKey(i => i.FK_InvoiceVersionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.ShopPOItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasMany(i => i.Payments)
|
||||
.WithOne(p => p.Invoice)
|
||||
@@ -594,11 +654,171 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
|
||||
);
|
||||
|
||||
// --- Payment service: Bank Accounts & Cheques ---
|
||||
|
||||
modelBuilder.Entity<BankAccount>()
|
||||
.Property(a => a.AccountName).HasMaxLength(200);
|
||||
modelBuilder.Entity<BankAccount>()
|
||||
.Property(a => a.BankName).HasMaxLength(200);
|
||||
modelBuilder.Entity<BankAccount>()
|
||||
.Property(a => a.CashAccountTypeName).HasMaxLength(100);
|
||||
modelBuilder.Entity<BankAccount>()
|
||||
.Property(a => a.AccountNumber).HasMaxLength(100);
|
||||
modelBuilder.Entity<BankAccount>()
|
||||
.Property(a => a.CurrencyCode).HasMaxLength(10);
|
||||
modelBuilder.Entity<BankAccount>()
|
||||
.HasIndex(a => new { a.AccountType, a.AccountName })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<BankAccount>().HasData(new BankAccount
|
||||
{
|
||||
Id = new Guid("11111111-1111-1111-1111-000000000001"),
|
||||
CreatedAt = new DateTime(2026, 10, 19),
|
||||
IsActive = true,
|
||||
AccountType = BankAccountType.Cash,
|
||||
AccountName = "Petty Cash",
|
||||
CashAccountTypeName = "Petty Cash",
|
||||
CurrencyCode = "LKR",
|
||||
IsDefault = true
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ChequeBook>()
|
||||
.Property(b => b.ChequeBookNo).HasMaxLength(100);
|
||||
modelBuilder.Entity<ChequeBook>()
|
||||
.Property(b => b.StartChequeNo).HasMaxLength(50);
|
||||
modelBuilder.Entity<ChequeBook>()
|
||||
.Property(b => b.EndChequeNo).HasMaxLength(50);
|
||||
modelBuilder.Entity<ChequeBook>()
|
||||
.HasIndex(b => b.ChequeBookNo).IsUnique();
|
||||
modelBuilder.Entity<ChequeBook>()
|
||||
.HasOne(b => b.BankAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.FK_BankAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<ChequePage>()
|
||||
.Property(p => p.ChequeNo).HasMaxLength(50);
|
||||
modelBuilder.Entity<ChequePage>()
|
||||
.Property(p => p.CurrencyCode).HasMaxLength(10);
|
||||
modelBuilder.Entity<ChequePage>()
|
||||
.HasIndex(p => p.ChequeNo).IsUnique();
|
||||
modelBuilder.Entity<ChequePage>()
|
||||
.HasOne(p => p.ChequeBook)
|
||||
.WithMany(b => b.Pages)
|
||||
.HasForeignKey(p => p.FK_ChequeBookId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<ReceivedCheque>()
|
||||
.Property(c => c.ChequeNo).HasMaxLength(50);
|
||||
modelBuilder.Entity<ReceivedCheque>()
|
||||
.Property(c => c.ReceivedFromName).HasMaxLength(200);
|
||||
modelBuilder.Entity<ReceivedCheque>()
|
||||
.HasIndex(c => c.ChequeNo);
|
||||
modelBuilder.Entity<ReceivedCheque>()
|
||||
.HasOne(c => c.DepositBankAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_DepositBankAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
// --- SupplierPayment ---
|
||||
|
||||
modelBuilder.Entity<SupplierPayment>()
|
||||
.HasOne(p => p.Supplier)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.FK_SupplierId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierPayment>()
|
||||
.HasOne(p => p.BankAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.FK_BankAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<SupplierPayment>()
|
||||
.HasOne(p => p.ChequePage)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.FK_ChequePageId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<SupplierPayment>()
|
||||
.Property(p => p.Amount).HasPrecision(18, 2);
|
||||
|
||||
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||
.HasOne(a => a.SupplierPayment)
|
||||
.WithMany(p => p.Allocations)
|
||||
.HasForeignKey(a => a.FK_SupplierPaymentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||
.HasOne(a => a.GoodsReceipt)
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.FK_GoodsReceiptId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||
.HasIndex(a => a.FK_GoodsReceiptId);
|
||||
|
||||
// --- Notifications ---
|
||||
|
||||
modelBuilder.Entity<Notification>()
|
||||
.HasIndex(n => new { n.EntityType, n.EntityId });
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasOne(r => r.Notification)
|
||||
.WithMany(n => n.Recipients)
|
||||
.HasForeignKey(r => r.FK_NotificationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasOne(r => r.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FK_UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasIndex(r => new { r.FK_UserId, r.IsRead });
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
|
||||
.IsUnique();
|
||||
|
||||
// --- StoreCreditMovement ---
|
||||
|
||||
modelBuilder.Entity<StoreCreditMovement>()
|
||||
.HasOne(m => m.Store)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.FK_StoreId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<StoreCreditMovement>()
|
||||
.HasOne(m => m.Invoice)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.FK_InvoiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<StoreCreditMovement>()
|
||||
.HasOne(m => m.Payment)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.FK_PaymentId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<StoreCreditMovement>()
|
||||
.Property(m => m.Amount).HasPrecision(18, 2);
|
||||
|
||||
modelBuilder.Entity<StoreCreditMovement>()
|
||||
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
|
||||
|
||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||
Seed.ProcurementSeed.Apply(modelBuilder);
|
||||
Seed.CommerceSeed.Apply(modelBuilder);
|
||||
Seed.OperationsExtrasSeed.Apply(modelBuilder);
|
||||
Seed.AdminSeed.Apply(modelBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Data.Seed;
|
||||
|
||||
public static class AdminSeed
|
||||
{
|
||||
private static readonly DateTime SeedDate = new(2026, 10, 13);
|
||||
|
||||
// Same GUID as the AuthHex user — the sub claim from the JWT maps to this Id
|
||||
internal static readonly Guid SuperAdminUserId = new("00000001-0000-0000-0000-000000000001");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<User>().HasData(new User
|
||||
{
|
||||
Id = SuperAdminUserId,
|
||||
Username = "superadmin",
|
||||
FullName = "Super Admin",
|
||||
FK_RoleId = RoleAndPermissionSeed.SuperAdminRoleId,
|
||||
IsActive = true,
|
||||
CreatedAt = SeedDate
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,6 @@ public static class MasterDataSeed
|
||||
{
|
||||
private static readonly DateTime SeedDate = new(2026, 8, 20);
|
||||
|
||||
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
|
||||
// Implementation Notes in the plan; not derivable from HasData at migration time.
|
||||
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
|
||||
|
||||
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
|
||||
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
|
||||
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
|
||||
@@ -115,12 +111,12 @@ public static class MasterDataSeed
|
||||
);
|
||||
|
||||
modelBuilder.Entity<User>().HasData(
|
||||
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
|
||||
new User { Id = Supervisor1UserId, Username = "sunil.fernando",FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = SalesRep1UserId, Username = "nimal.perera",FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = SalesRep2UserId, Username = "kamal.silva",FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya",FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = Driver2UserId, Username = "priyantha.kumara",FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa",FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<DriverProfile>().HasData(
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class OperationsExtrasSeed
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<User>().HasData(
|
||||
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Uom>().HasData(
|
||||
|
||||
@@ -7,22 +7,191 @@ public static class RoleAndPermissionSeed
|
||||
{
|
||||
private static readonly DateTime SeedDate = new(2026, 8, 18);
|
||||
|
||||
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
|
||||
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
|
||||
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
|
||||
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
|
||||
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
|
||||
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
|
||||
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
|
||||
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
|
||||
internal static readonly Guid SuperAdminRoleId = new("10000000-0000-0000-0000-000000000007");
|
||||
|
||||
// ── Legacy broad permissions (nav.ts uses these for sidebar visibility) ────
|
||||
private static readonly Guid PermOrdersView = new("20000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid PermRidesView = new("20000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid PermLoadingView = new("20000000-0000-0000-0000-000000000003");
|
||||
private static readonly Guid PermStoresView = new("20000000-0000-0000-0000-000000000004");
|
||||
private static readonly Guid PermSalesVisitsCheckin = new("20000000-0000-0000-0000-000000000005");
|
||||
private static readonly Guid PermItemsView = new("20000000-0000-0000-0000-000000000006");
|
||||
private static readonly Guid PermCategoriesView = new("20000000-0000-0000-0000-000000000007");
|
||||
private static readonly Guid PermWarehousesView = new("20000000-0000-0000-0000-000000000008");
|
||||
private static readonly Guid PermPurchaseOrdersView = new("20000000-0000-0000-0000-000000000009");
|
||||
private static readonly Guid PermPricelistsView = new("20000000-0000-0000-0000-000000000010");
|
||||
private static readonly Guid PermSchemesView = new("20000000-0000-0000-0000-000000000011");
|
||||
private static readonly Guid PermSuppliersView = new("20000000-0000-0000-0000-000000000012");
|
||||
private static readonly Guid PermDriverProfilesView = new("20000000-0000-0000-0000-000000000013");
|
||||
private static readonly Guid PermComplianceManage = new("20000000-0000-0000-0000-000000000014");
|
||||
private static readonly Guid PermUsersView = new("20000000-0000-0000-0000-000000000015");
|
||||
private static readonly Guid PermRolesView = new("20000000-0000-0000-0000-000000000016");
|
||||
private static readonly Guid PermRegionsView = new("20000000-0000-0000-0000-000000000017");
|
||||
private static readonly Guid PermRoutesView = new("20000000-0000-0000-0000-000000000018");
|
||||
private static readonly Guid PermVehiclesView = new("20000000-0000-0000-0000-000000000019");
|
||||
|
||||
// ── Granular API permissions (used in [Authorize("...")] on controller actions) ─
|
||||
private static readonly Guid PermClaimView = new("20000000-0000-0000-0000-000000000020");
|
||||
private static readonly Guid PermClaimManage = new("20000000-0000-0000-0000-000000000021");
|
||||
private static readonly Guid PermDeliveryTaskView = new("20000000-0000-0000-0000-000000000022");
|
||||
private static readonly Guid PermDeliveryTaskManage = new("20000000-0000-0000-0000-000000000023");
|
||||
private static readonly Guid PermDriverView = new("20000000-0000-0000-0000-000000000024");
|
||||
private static readonly Guid PermDriverManage = new("20000000-0000-0000-0000-000000000025");
|
||||
private static readonly Guid PermGrnView = new("20000000-0000-0000-0000-000000000026");
|
||||
private static readonly Guid PermGrnManage = new("20000000-0000-0000-0000-000000000027");
|
||||
private static readonly Guid PermLoadingSheetView = new("20000000-0000-0000-0000-000000000028");
|
||||
private static readonly Guid PermLoadingSheetManage = new("20000000-0000-0000-0000-000000000029");
|
||||
private static readonly Guid PermPriceListView = new("20000000-0000-0000-0000-000000000030");
|
||||
private static readonly Guid PermPriceListManage = new("20000000-0000-0000-0000-000000000031");
|
||||
private static readonly Guid PermPurchaseOrderView = new("20000000-0000-0000-0000-000000000032");
|
||||
private static readonly Guid PermPurchaseOrderManage= new("20000000-0000-0000-0000-000000000033");
|
||||
private static readonly Guid PermPurchaseReturnManage=new("20000000-0000-0000-0000-000000000034");
|
||||
private static readonly Guid PermRegionView = new("20000000-0000-0000-0000-000000000035");
|
||||
private static readonly Guid PermRegionManage = new("20000000-0000-0000-0000-000000000036");
|
||||
private static readonly Guid PermReturnView = new("20000000-0000-0000-0000-000000000037");
|
||||
private static readonly Guid PermReturnManage = new("20000000-0000-0000-0000-000000000038");
|
||||
private static readonly Guid PermRouteView = new("20000000-0000-0000-0000-000000000039");
|
||||
private static readonly Guid PermRouteManage = new("20000000-0000-0000-0000-000000000040");
|
||||
private static readonly Guid PermSchemeView = new("20000000-0000-0000-0000-000000000041");
|
||||
private static readonly Guid PermSchemeManage = new("20000000-0000-0000-0000-000000000042");
|
||||
private static readonly Guid PermStockView = new("20000000-0000-0000-0000-000000000043");
|
||||
private static readonly Guid PermStockManage = new("20000000-0000-0000-0000-000000000044");
|
||||
private static readonly Guid PermStoreView = new("20000000-0000-0000-0000-000000000045");
|
||||
private static readonly Guid PermStoreManage = new("20000000-0000-0000-0000-000000000046");
|
||||
private static readonly Guid PermSupplierClaimView = new("20000000-0000-0000-0000-000000000047");
|
||||
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
||||
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Role>().HasData(
|
||||
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
|
||||
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = RegionalManagerId,Name = "RegionalManager",Level = 50, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = SuperAdminRoleId, Name = "SuperAdmin", Level = 100, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Permission>().HasData(
|
||||
// ── Legacy broad permissions ──────────────────────────────────────────
|
||||
new Permission { Id = PermOrdersView, Code = "orders.view", Description = "View shop POs, invoices, payments and returns", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRidesView, Code = "rides.view", Description = "View and manage delivery rides and delivery tasks", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermLoadingView, Code = "loading.view", Description = "View and manage loading sheets", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermStoresView, Code = "stores.view", Description = "View store accounts, credit limits and balances", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSalesVisitsCheckin, Code = "sales_visits.checkin", Description = "Check in and out of scheduled store visits", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermItemsView, Code = "items.view", Description = "View and manage catalogue products", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermCategoriesView, Code = "categories.view", Description = "View and manage product categories and brands", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermWarehousesView, Code = "warehouses.view", Description = "View stock balances, movements, warehouses and reports", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermPurchaseOrdersView, Code = "purchase_orders.view", Description = "View and manage purchase orders, GRNs and supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermPricelistsView, Code = "pricelists.view", Description = "View and manage regional price lists", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSchemesView, Code = "schemes.view", Description = "View and manage pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSuppliersView, Code = "suppliers.view", Description = "View and manage supplier accounts", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermDriverProfilesView, Code = "driver_profiles.view", Description = "View and manage driver profiles and licensing", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermComplianceManage, Code = "compliance.manage", Description = "Manage driver compliance rules", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermUsersView, Code = "users.view", Description = "View and manage staff user accounts", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRolesView, Code = "roles.view", Description = "View and manage roles and their permission sets", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRegionsView, Code = "regions.view", Description = "View and manage regions and region configuration", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRoutesView, Code = "routes.view", Description = "View and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermVehiclesView, Code = "vehicles.view", Description = "View and manage fleet vehicles", CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// ── Granular API permissions ──────────────────────────────────────────
|
||||
new Permission { Id = PermClaimView, Code = "claim.view", Description = "View agency claims and settlement status", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermClaimManage, Code = "claim.manage", Description = "Create and settle agency claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermDeliveryTaskView, Code = "delivery-task.view", Description = "View delivery tasks within a ride", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermDeliveryTaskManage, Code = "delivery-task.manage", Description = "Confirm and fail delivery tasks", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermDriverView, Code = "driver.view", Description = "View driver profiles and license details", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermDriverManage, Code = "driver.manage", Description = "Create and update driver profiles", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermGrnView, Code = "grn.view", Description = "View goods receipt notes", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermGrnManage, Code = "grn.manage", Description = "Create and confirm goods receipts", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermLoadingSheetView, Code = "loading-sheet.view", Description = "View loading sheets and their items", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermLoadingSheetManage, Code = "loading-sheet.manage", Description = "Create, edit, and confirm loading sheets", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermPriceListView, Code = "price-list.view", Description = "View regional price lists", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermPriceListManage, Code = "price-list.manage", Description = "Create and manage item price lists", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermPurchaseOrderView, Code = "purchase-order.view", Description = "View purchase orders and line items", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermPurchaseOrderManage,Code = "purchase-order.manage", Description = "Create, confirm, and close purchase orders", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermPurchaseReturnManage,Code= "purchase-return.manage",Description = "Create and manage purchase returns to suppliers", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRegionView, Code = "region.view", Description = "View regions and their configuration", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRegionManage, Code = "region.manage", Description = "Create and update regions, targets, and budgets", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermReturnView, Code = "return.view", Description = "View order returns", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermReturnManage, Code = "return.manage", Description = "Create and manage order returns", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRouteView, Code = "route.view", Description = "View delivery routes", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermRouteManage, Code = "route.manage", Description = "Create and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSchemeView, Code = "scheme.view", Description = "View pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSchemeManage, Code = "scheme.manage", Description = "Create and configure pricing schemes", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermStockView, Code = "stock.view", Description = "View stock balances and movements", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermStockManage, Code = "stock.manage", Description = "Transfer stock and record damage or adjustments", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermStoreView, Code = "store.view", Description = "View store accounts and balances", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermStoreManage, Code = "store.manage", Description = "Create and update store accounts", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
// SuperAdmin gets every permission
|
||||
modelBuilder.Entity<RolePermission>().HasData(
|
||||
// ── Legacy broad permissions ──────────────────────────────────────────
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermOrdersView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRidesView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoresView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSalesVisitsCheckin, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermItemsView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermCategoriesView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermWarehousesView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrdersView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPricelistsView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemesView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSuppliersView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverProfilesView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermComplianceManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermUsersView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRolesView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionsView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRoutesView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermVehiclesView, CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// ── Granular API permissions ──────────────────────────────────────────
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderManage,CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseReturnManage,CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Distribution.Features.User;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -6,32 +8,93 @@ namespace Distribution.Features.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
|
||||
public class AuthController(AuthService service, ICurrentUserService currentUser, IUserRepository userRepo) : 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, CancellationToken ct) =>
|
||||
ToActionResult(await service.LoginAsync(request, ct));
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.LoginAsync(request, ct);
|
||||
|
||||
if (!result.Success || result.Data is not { } data)
|
||||
return ToActionResult(result);
|
||||
|
||||
string? token = null;
|
||||
TimeSpan maxAge = TimeSpan.FromHours(1);
|
||||
|
||||
if (data.TryGetProperty("accessToken", out var tokenEl))
|
||||
token = tokenEl.GetString();
|
||||
|
||||
if (data.TryGetProperty("expiresIn", out var expEl))
|
||||
maxAge = TimeSpan.FromSeconds(expEl.GetInt32());
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
Response.Cookies.Append("access-token", token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
MaxAge = maxAge,
|
||||
Secure = Request.IsHttps,
|
||||
});
|
||||
}
|
||||
|
||||
MeResponse? profile = null;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
|
||||
var userIdStr = jwt.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value;
|
||||
if (Guid.TryParse(userIdStr, out var userId))
|
||||
{
|
||||
var user = await userRepo.GetByIdAsync(userId);
|
||||
if (user is not null)
|
||||
profile = new MeResponse(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.FK_RoleId,
|
||||
user.Role?.Name ?? string.Empty,
|
||||
user.Role?.Level ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { result.StatusCode, result.Success, result.Message, result.Data, profile });
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
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)
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
|
||||
if (currentUser.UserId is null) return Unauthorized();
|
||||
var user = await userRepo.GetByIdAsync(currentUser.UserId.Value);
|
||||
if (user is null) return Unauthorized();
|
||||
return Ok(new MeResponse(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.FK_RoleId,
|
||||
user.Role?.Name ?? string.Empty,
|
||||
user.Role?.Level ?? 0));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||
[AllowAnonymous]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
|
||||
return ToActionResult(await service.LogoutAsync(token, ct));
|
||||
Response.Cookies.Delete("access-token", new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
Secure = Request.IsHttps,
|
||||
});
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
public record LoginRequest(
|
||||
string Identifier,
|
||||
string Password,
|
||||
Guid? UserTypeId = null,
|
||||
string DeviceName = "Unknown Device");
|
||||
|
||||
public record RegisterRequest(string Username, string Password, string? FullName);
|
||||
public record RegisterRequest(string Username, string email, string Password, string? FullName);
|
||||
|
||||
public record MeResponse(
|
||||
Guid UserId,
|
||||
string Username,
|
||||
Guid RoleId,
|
||||
string RoleCode,
|
||||
int RoleLevel);
|
||||
|
||||
+3
-4
@@ -1,12 +1,11 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
/// <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.
|
||||
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates;
|
||||
/// this app never sees or stores a plaintext/hashed password.
|
||||
/// </summary>
|
||||
public class AuthHexClient(HttpClient http) : IAuthHexClient
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
|
||||
public record AuthHexResponse(
|
||||
@@ -1,17 +1,21 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
/// <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).
|
||||
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates.
|
||||
/// </summary>
|
||||
public class AuthService(IAuthHexClient authHex)
|
||||
{
|
||||
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||
authHex.CallAsync("loginUser", new
|
||||
{
|
||||
identifier = request.Identifier,
|
||||
password = request.Password,
|
||||
userTypeId = request.UserTypeId,
|
||||
deviceName = request.DeviceName
|
||||
}, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||
|
||||
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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);
|
||||
// }
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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.
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace Distribution.Infra.Authorization;
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
public interface IAuthHexClient
|
||||
{
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.BankAccount;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
public class BankAccountController(BankAccountService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("bank-accounts")]
|
||||
public async Task<IActionResult> List([FromQuery] BankAccountType? accountType)
|
||||
{
|
||||
var result = await service.ListAsync(accountType);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("bank-accounts")]
|
||||
public async Task<IActionResult> CreateBank(CreateBankAccountRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateBankAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("cash-accounts")]
|
||||
public async Task<IActionResult> CreateCash(CreateCashAccountRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateCashAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("cash-accounts/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateCash(Guid id, UpdateCashAccountRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateCashAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("cash-accounts/{id:guid}/status")]
|
||||
public async Task<IActionResult> SetCashStatus(Guid id, SetCashAccountStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.SetCashStatusAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpDelete("cash-accounts/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteCash(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ok = await service.DeleteCashAsync(id, ct);
|
||||
return ok ? NoContent() : NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("cash-account-types")]
|
||||
public async Task<IActionResult> ListCashAccountTypes() => Ok(await service.ListCashAccountTypesAsync());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.BankAccount;
|
||||
|
||||
public record BankAccountResponse(
|
||||
BankAccountType AccountType,
|
||||
Guid AccountId,
|
||||
string AccountName,
|
||||
string? BankName,
|
||||
string? CashAccountTypeName,
|
||||
string? AccountNumber,
|
||||
string CurrencyCode,
|
||||
DateTime CreatedAt,
|
||||
bool? IsActive,
|
||||
bool? IsDefault);
|
||||
|
||||
public record CreateBankAccountRequest(
|
||||
string AccountName,
|
||||
string? BankName,
|
||||
string? AccountNumber,
|
||||
string? CurrencyCode);
|
||||
|
||||
public record CreateCashAccountRequest(
|
||||
string AccountName,
|
||||
string CashAccountTypeName,
|
||||
string? AccountNumber,
|
||||
string? CurrencyCode);
|
||||
|
||||
public record UpdateCashAccountRequest(string AccountName);
|
||||
|
||||
public record SetCashAccountStatusRequest(bool IsActive);
|
||||
|
||||
public record CashAccountTypeDto(string Name);
|
||||
@@ -0,0 +1,40 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.BankAccount;
|
||||
|
||||
public class BankAccountRepository(AppDBContext context) : IBankAccountRepository
|
||||
{
|
||||
public async Task<Models.BankAccount?> GetByIdAsync(Guid id) =>
|
||||
await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
public async Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType)
|
||||
{
|
||||
var query = context.BankAccounts.AsQueryable();
|
||||
if (accountType is not null)
|
||||
query = query.Where(a => a.AccountType == accountType);
|
||||
return await query.OrderByDescending(a => a.CreatedAt).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<string>> ListCashAccountTypeNamesAsync() =>
|
||||
await context.BankAccounts
|
||||
.Where(a => a.AccountType == BankAccountType.Cash && a.CashAccountTypeName != null)
|
||||
.Select(a => a.CashAccountTypeName!)
|
||||
.Distinct()
|
||||
.OrderBy(n => n)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null) =>
|
||||
await context.BankAccounts.AnyAsync(a =>
|
||||
a.AccountType == type &&
|
||||
a.AccountName == accountName &&
|
||||
(excludeId == null || a.Id != excludeId));
|
||||
|
||||
public async Task AddAsync(Models.BankAccount account) =>
|
||||
await context.BankAccounts.AddAsync(account);
|
||||
|
||||
public void Update(Models.BankAccount account) => context.BankAccounts.Update(account);
|
||||
|
||||
public void Remove(Models.BankAccount account) => context.BankAccounts.Remove(account);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.BankAccount;
|
||||
|
||||
public class BankAccountService(IBankAccountRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private const string DefaultCurrency = "LKR";
|
||||
|
||||
private static BankAccountResponse ToResponse(Models.BankAccount a) =>
|
||||
new(
|
||||
a.AccountType,
|
||||
a.Id,
|
||||
a.AccountName,
|
||||
a.AccountType == BankAccountType.Bank ? a.BankName : null,
|
||||
a.AccountType == BankAccountType.Cash ? a.CashAccountTypeName : null,
|
||||
a.AccountNumber,
|
||||
a.CurrencyCode,
|
||||
a.CreatedAt,
|
||||
a.AccountType == BankAccountType.Cash ? a.IsActive : null,
|
||||
a.AccountType == BankAccountType.Cash ? a.IsDefault : null);
|
||||
|
||||
public async Task<List<BankAccountResponse>> ListAsync(BankAccountType? accountType) =>
|
||||
(await repo.ListAsync(accountType)).Select(ToResponse).ToList();
|
||||
|
||||
public async Task<List<CashAccountTypeDto>> ListCashAccountTypesAsync() =>
|
||||
(await repo.ListCashAccountTypeNamesAsync()).Select(n => new CashAccountTypeDto(n)).ToList();
|
||||
|
||||
public async Task<BankAccountResponse> CreateBankAsync(CreateBankAccountRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.AccountName))
|
||||
throw new InvalidOperationException("AccountName is required");
|
||||
if (await repo.ExistsByNameAsync(BankAccountType.Bank, request.AccountName))
|
||||
throw new InvalidOperationException("A bank account with this name already exists");
|
||||
|
||||
var account = new Models.BankAccount
|
||||
{
|
||||
AccountType = BankAccountType.Bank,
|
||||
AccountName = request.AccountName,
|
||||
BankName = request.BankName,
|
||||
AccountNumber = request.AccountNumber,
|
||||
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
|
||||
IsActive = true,
|
||||
IsDefault = false
|
||||
};
|
||||
|
||||
await repo.AddAsync(account);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToResponse(account);
|
||||
}
|
||||
|
||||
public async Task<BankAccountResponse> CreateCashAsync(CreateCashAccountRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.AccountName))
|
||||
throw new InvalidOperationException("AccountName is required");
|
||||
if (string.IsNullOrWhiteSpace(request.CashAccountTypeName))
|
||||
throw new InvalidOperationException("CashAccountTypeName is required");
|
||||
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName))
|
||||
throw new InvalidOperationException("A cash account with this name already exists");
|
||||
|
||||
var account = new Models.BankAccount
|
||||
{
|
||||
AccountType = BankAccountType.Cash,
|
||||
AccountName = request.AccountName,
|
||||
CashAccountTypeName = request.CashAccountTypeName,
|
||||
AccountNumber = request.AccountNumber,
|
||||
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
|
||||
IsActive = true,
|
||||
IsDefault = false
|
||||
};
|
||||
|
||||
await repo.AddAsync(account);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToResponse(account);
|
||||
}
|
||||
|
||||
public async Task<BankAccountResponse?> UpdateCashAsync(Guid id, UpdateCashAccountRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var account = await repo.GetByIdAsync(id);
|
||||
if (account is null || account.AccountType != BankAccountType.Cash) return null;
|
||||
if (string.IsNullOrWhiteSpace(request.AccountName))
|
||||
throw new InvalidOperationException("AccountName is required");
|
||||
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName, excludeId: id))
|
||||
throw new InvalidOperationException("A cash account with this name already exists");
|
||||
|
||||
account.AccountName = request.AccountName;
|
||||
repo.Update(account);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToResponse(account);
|
||||
}
|
||||
|
||||
public async Task<BankAccountResponse?> SetCashStatusAsync(Guid id, SetCashAccountStatusRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var account = await repo.GetByIdAsync(id);
|
||||
if (account is null || account.AccountType != BankAccountType.Cash) return null;
|
||||
if (account.IsDefault && !request.IsActive)
|
||||
throw new InvalidOperationException("The default cash account cannot be deactivated");
|
||||
|
||||
account.IsActive = request.IsActive;
|
||||
repo.Update(account);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToResponse(account);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCashAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var account = await repo.GetByIdAsync(id);
|
||||
if (account is null || account.AccountType != BankAccountType.Cash) return false;
|
||||
if (account.IsDefault)
|
||||
throw new InvalidOperationException("The default cash account cannot be deleted");
|
||||
|
||||
repo.Remove(account);
|
||||
await uow.CommitAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.BankAccount;
|
||||
|
||||
public interface IBankAccountRepository
|
||||
{
|
||||
Task<Models.BankAccount?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType);
|
||||
Task<List<string>> ListCashAccountTypeNamesAsync();
|
||||
Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null);
|
||||
Task AddAsync(Models.BankAccount account);
|
||||
void Update(Models.BankAccount account);
|
||||
void Remove(Models.BankAccount account);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ChequeBook;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
public class ChequeBookController(ChequeBookService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("cheque-books")]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] Guid? bankAccountId,
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] ChequeBookStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50)
|
||||
=> Ok(await service.ListAsync(bankAccountId, branchId, status, page, pageSize));
|
||||
|
||||
[HttpGet("cheque-books/{chequeBookNo}")]
|
||||
public async Task<IActionResult> Get(string chequeBookNo, [FromQuery] string? expand)
|
||||
{
|
||||
var expandPages = string.Equals(expand, "pages", StringComparison.OrdinalIgnoreCase);
|
||||
var result = await service.GetAsync(chequeBookNo, expandPages);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("cheque-books")]
|
||||
public async Task<IActionResult> Create(CreateChequeBookRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("cheque-pages/{chequeNo}/issue")]
|
||||
public async Task<IActionResult> Issue(string chequeNo, IssueChequePageRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.IssueAsync(chequeNo, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("cheque-pages/{chequeNo}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdatePageStatusAsync(chequeNo, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ChequeBook;
|
||||
|
||||
public record ChequePageResponse(
|
||||
Guid Id,
|
||||
string ChequeNo,
|
||||
string ChequeBookNo,
|
||||
ChequePageIssueStatus IssueStatus,
|
||||
PayeeType? PayeeType,
|
||||
int? PayeeId,
|
||||
string? PayeeName,
|
||||
DateTime? IssueDate,
|
||||
decimal? Amount,
|
||||
string? CurrencyCode,
|
||||
int? VoucherId,
|
||||
string? ReferenceNo,
|
||||
string? Purpose,
|
||||
bool? IsCrossCheque,
|
||||
bool? IsAccountPayee,
|
||||
bool? IsPostDated,
|
||||
string? Notes,
|
||||
string? PrintedBy,
|
||||
DateTime? PrintedAt,
|
||||
DateTime? ClearedDate,
|
||||
bool? ClearedByBank,
|
||||
string? CancelReason);
|
||||
|
||||
public record ChequeBookResponse(
|
||||
string ChequeBookNo,
|
||||
int BranchId,
|
||||
Guid BankAccountId,
|
||||
string StartChequeNo,
|
||||
string EndChequeNo,
|
||||
int TotalLeaves,
|
||||
DateTime ReceivedDate,
|
||||
string? Description,
|
||||
string? CreatedBy,
|
||||
ChequeBookStatus Status,
|
||||
List<ChequePageResponse> Pages);
|
||||
|
||||
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int? Page, int? PageSize);
|
||||
|
||||
public record CreateChequeBookRequest(
|
||||
int BranchId,
|
||||
Guid BankAccountId,
|
||||
string ChequeBookNo,
|
||||
string StartChequeNo,
|
||||
string EndChequeNo,
|
||||
int TotalLeaves,
|
||||
DateTime ReceivedDate,
|
||||
string? Description,
|
||||
string? CreatedBy);
|
||||
|
||||
public record IssueChequePageRequest(
|
||||
PayeeType PayeeType,
|
||||
int? PayeeId,
|
||||
string PayeeName,
|
||||
DateTime IssueDate,
|
||||
decimal Amount,
|
||||
string? CurrencyCode,
|
||||
int? VoucherId,
|
||||
string? ReferenceNo,
|
||||
string? Purpose,
|
||||
bool? IsCrossCheque,
|
||||
bool? IsAccountPayee,
|
||||
bool? IsPostDated,
|
||||
string? Notes,
|
||||
string? PrintedBy);
|
||||
|
||||
public record UpdateChequePageStatusRequest(
|
||||
ChequePageStatusAction Action,
|
||||
DateTime? ClearedDate,
|
||||
string? CancelReason,
|
||||
string? PerformedBy);
|
||||
@@ -0,0 +1,46 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.ChequeBook;
|
||||
|
||||
public class ChequeBookRepository(AppDBContext context) : IChequeBookRepository
|
||||
{
|
||||
public async Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages)
|
||||
{
|
||||
var query = context.ChequeBooks.AsQueryable();
|
||||
if (includePages) query = query.Include(b => b.Pages);
|
||||
return await query.FirstOrDefaultAsync(b => b.ChequeBookNo == chequeBookNo);
|
||||
}
|
||||
|
||||
public async Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
|
||||
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
|
||||
{
|
||||
var query = context.ChequeBooks.AsQueryable();
|
||||
if (bankAccountId is not null) query = query.Where(b => b.FK_BankAccountId == bankAccountId);
|
||||
if (branchId is not null) query = query.Where(b => b.BranchId == branchId);
|
||||
if (status is not null) query = query.Where(b => b.Status == status);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<bool> BookNoExistsAsync(string chequeBookNo) =>
|
||||
await context.ChequeBooks.AnyAsync(b => b.ChequeBookNo == chequeBookNo);
|
||||
|
||||
public async Task AddAsync(Models.ChequeBook book) => await context.ChequeBooks.AddAsync(book);
|
||||
|
||||
public void Update(Models.ChequeBook book) => context.ChequeBooks.Update(book);
|
||||
|
||||
public async Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo) =>
|
||||
await context.ChequePages
|
||||
.Include(p => p.ChequeBook)
|
||||
.FirstOrDefaultAsync(p => p.ChequeNo == chequeNo);
|
||||
|
||||
public void UpdatePage(Models.ChequePage page) => context.ChequePages.Update(page);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ChequeBook;
|
||||
|
||||
public class ChequeBookService(
|
||||
IChequeBookRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string DefaultCurrency = "LKR";
|
||||
|
||||
private static ChequePageResponse ToPageResponse(Models.ChequePage p, string chequeBookNo) =>
|
||||
new(p.Id, p.ChequeNo, chequeBookNo, p.IssueStatus, p.PayeeType, p.PayeeId, p.PayeeName,
|
||||
p.IssueDate, p.Amount, p.CurrencyCode, p.VoucherId, p.ReferenceNo, p.Purpose,
|
||||
p.IsCrossCheque, p.IsAccountPayee, p.IsPostDated, p.Notes,
|
||||
p.PrintedBy, p.PrintedAt, p.ClearedDate, p.ClearedByBank, p.CancelReason);
|
||||
|
||||
private static ChequeBookResponse ToBookResponse(Models.ChequeBook b, bool includePages) =>
|
||||
new(b.ChequeBookNo, b.BranchId, b.FK_BankAccountId, b.StartChequeNo, b.EndChequeNo,
|
||||
b.TotalLeaves, b.ReceivedDate, b.Description, b.CreatedBy, b.Status,
|
||||
includePages ? b.Pages.OrderBy(p => p.ChequeNo).Select(p => ToPageResponse(p, b.ChequeBookNo)).ToList() : new());
|
||||
|
||||
public async Task<PagedResult<ChequeBookResponse>> ListAsync(
|
||||
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 50;
|
||||
|
||||
var (items, total) = await repo.ListAsync(bankAccountId, branchId, status, page, pageSize);
|
||||
return new PagedResult<ChequeBookResponse>(
|
||||
items.Select(b => ToBookResponse(b, includePages: false)).ToList(),
|
||||
total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<ChequeBookResponse?> GetAsync(string chequeBookNo, bool expandPages)
|
||||
{
|
||||
var book = await repo.GetByNoAsync(chequeBookNo, includePages: expandPages);
|
||||
return book is null ? null : ToBookResponse(book, includePages: expandPages);
|
||||
}
|
||||
|
||||
public async Task<ChequeBookResponse> CreateAsync(CreateChequeBookRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ChequeBookNo))
|
||||
throw new InvalidOperationException("ChequeBookNo is required");
|
||||
if (request.TotalLeaves <= 0)
|
||||
throw new InvalidOperationException("TotalLeaves must be greater than zero");
|
||||
if (!long.TryParse(request.StartChequeNo, out var start) ||
|
||||
!long.TryParse(request.EndChequeNo, out var end))
|
||||
throw new InvalidOperationException("StartChequeNo and EndChequeNo must be numeric");
|
||||
if (end < start)
|
||||
throw new InvalidOperationException("EndChequeNo must be greater than or equal to StartChequeNo");
|
||||
if (end - start + 1 != request.TotalLeaves)
|
||||
throw new InvalidOperationException("TotalLeaves does not match the Start/End cheque range");
|
||||
if (await repo.BookNoExistsAsync(request.ChequeBookNo))
|
||||
throw new InvalidOperationException("A cheque book with this number already exists");
|
||||
|
||||
var bankAccount = await bankAccountRepo.GetByIdAsync(request.BankAccountId);
|
||||
if (bankAccount is null || bankAccount.AccountType != BankAccountType.Bank)
|
||||
throw new InvalidOperationException("BankAccountId does not reference a valid bank account");
|
||||
|
||||
var book = new Models.ChequeBook
|
||||
{
|
||||
ChequeBookNo = request.ChequeBookNo,
|
||||
BranchId = request.BranchId,
|
||||
FK_BankAccountId = request.BankAccountId,
|
||||
StartChequeNo = request.StartChequeNo,
|
||||
EndChequeNo = request.EndChequeNo,
|
||||
TotalLeaves = request.TotalLeaves,
|
||||
ReceivedDate = request.ReceivedDate,
|
||||
Description = request.Description,
|
||||
CreatedBy = request.CreatedBy,
|
||||
Status = ChequeBookStatus.Active
|
||||
};
|
||||
|
||||
var width = request.StartChequeNo.Length;
|
||||
for (var n = start; n <= end; n++)
|
||||
{
|
||||
book.Pages.Add(new Models.ChequePage
|
||||
{
|
||||
ChequeNo = n.ToString().PadLeft(width, '0'),
|
||||
IssueStatus = ChequePageIssueStatus.Unused
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(book);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToBookResponse(book, includePages: true);
|
||||
}
|
||||
|
||||
public async Task<ChequePageResponse?> IssueAsync(string chequeNo, IssueChequePageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var page = await repo.GetPageByNoAsync(chequeNo);
|
||||
if (page is null) return null;
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Unused)
|
||||
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
|
||||
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
|
||||
throw new InvalidOperationException("The cheque book is not active");
|
||||
if (request.Amount <= 0)
|
||||
throw new InvalidOperationException("Amount must be greater than zero");
|
||||
if (string.IsNullOrWhiteSpace(request.PayeeName))
|
||||
throw new InvalidOperationException("PayeeName is required");
|
||||
|
||||
page.IssueStatus = ChequePageIssueStatus.Issued;
|
||||
page.PayeeType = request.PayeeType;
|
||||
page.PayeeId = request.PayeeId;
|
||||
page.PayeeName = request.PayeeName;
|
||||
page.IssueDate = request.IssueDate;
|
||||
page.Amount = request.Amount;
|
||||
page.CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode;
|
||||
page.VoucherId = request.VoucherId;
|
||||
page.ReferenceNo = request.ReferenceNo;
|
||||
page.Purpose = request.Purpose;
|
||||
page.IsCrossCheque = request.IsCrossCheque;
|
||||
page.IsAccountPayee = request.IsAccountPayee;
|
||||
page.IsPostDated = request.IsPostDated;
|
||||
page.Notes = request.Notes;
|
||||
page.PrintedBy = request.PrintedBy;
|
||||
page.PrintedAt = DateTime.Now;
|
||||
|
||||
repo.UpdatePage(page);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
||||
}
|
||||
|
||||
public async Task<ChequePageResponse?> UpdatePageStatusAsync(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var page = await repo.GetPageByNoAsync(chequeNo);
|
||||
if (page is null) return null;
|
||||
|
||||
switch (request.Action)
|
||||
{
|
||||
case ChequePageStatusAction.Clear:
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Issued)
|
||||
throw new InvalidOperationException("Only issued cheques can be cleared");
|
||||
if (request.ClearedDate is null)
|
||||
throw new InvalidOperationException("ClearedDate is required when clearing a cheque");
|
||||
page.IssueStatus = ChequePageIssueStatus.Cleared;
|
||||
page.ClearedDate = request.ClearedDate;
|
||||
page.ClearedByBank = true;
|
||||
break;
|
||||
|
||||
case ChequePageStatusAction.Bounce:
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Issued)
|
||||
throw new InvalidOperationException("Only issued cheques can be bounced");
|
||||
page.IssueStatus = ChequePageIssueStatus.Bounced;
|
||||
break;
|
||||
|
||||
case ChequePageStatusAction.Cancel:
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Issued)
|
||||
throw new InvalidOperationException("Only issued cheques can be cancelled");
|
||||
if (string.IsNullOrWhiteSpace(request.CancelReason))
|
||||
throw new InvalidOperationException("CancelReason is required when cancelling a cheque");
|
||||
page.IssueStatus = ChequePageIssueStatus.Cancelled;
|
||||
page.CancelReason = request.CancelReason;
|
||||
break;
|
||||
|
||||
case ChequePageStatusAction.Void:
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Unused)
|
||||
throw new InvalidOperationException("Only unused cheques can be voided");
|
||||
page.IssueStatus = ChequePageIssueStatus.Void;
|
||||
break;
|
||||
}
|
||||
|
||||
repo.UpdatePage(page);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ChequeBook;
|
||||
|
||||
public interface IChequeBookRepository
|
||||
{
|
||||
Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages);
|
||||
Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
|
||||
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize);
|
||||
Task<bool> BookNoExistsAsync(string chequeBookNo);
|
||||
Task AddAsync(Models.ChequeBook book);
|
||||
void Update(Models.ChequeBook book);
|
||||
|
||||
Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo);
|
||||
void UpdatePage(Models.ChequePage page);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
@@ -7,10 +8,12 @@ namespace Distribution.Features.Claims;
|
||||
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/claims")]
|
||||
[Authorize("claim.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("api/claims/{id:guid}")]
|
||||
[Authorize("claim.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -18,6 +21,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpPost("api/claims")]
|
||||
[Authorize("claim.manage")]
|
||||
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -29,6 +33,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpPut("api/claims/{id:guid}")]
|
||||
[Authorize("claim.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -40,6 +45,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpPost("api/claims/{id:guid}/settle")]
|
||||
[Authorize("claim.manage")]
|
||||
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.SettledByUserId;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DashboardReport;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/reports/dashboard")]
|
||||
public class DashboardReportController(DashboardReportService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("sales")]
|
||||
public async Task<IActionResult> Sales(
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||
[FromQuery] Guid? regionId, [FromQuery] Guid? categoryId)
|
||||
{
|
||||
var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null);
|
||||
var error = DashboardReportService.Validate(f);
|
||||
return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
[HttpGet("inventory")]
|
||||
public async Task<IActionResult> Inventory(
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||
[FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId)
|
||||
{
|
||||
var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId);
|
||||
var error = DashboardReportService.Validate(f);
|
||||
return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Distribution.Features.DashboardReport;
|
||||
|
||||
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
||||
|
||||
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||
|
||||
public record SalesKpis(
|
||||
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||
|
||||
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
||||
|
||||
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
|
||||
|
||||
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
|
||||
|
||||
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
|
||||
|
||||
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
||||
|
||||
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
||||
|
||||
public record SalesSummaryResponse(
|
||||
DateTime From, DateTime To, string Granularity,
|
||||
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
|
||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||
|
||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||
|
||||
public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net);
|
||||
|
||||
public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value);
|
||||
|
||||
public record CategoryValueRow(string Category, decimal Quantity, decimal Value);
|
||||
|
||||
public record InventorySummaryResponse(
|
||||
DateTime From, DateTime To, string Granularity,
|
||||
List<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
|
||||
@@ -0,0 +1,226 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.DashboardReport;
|
||||
|
||||
public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository
|
||||
{
|
||||
private sealed class SoldLine
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public Guid PoId { get; set; }
|
||||
public decimal Value { get; set; }
|
||||
public decimal? Cost { get; set; }
|
||||
public Guid CategoryId { get; set; }
|
||||
public string CategoryName { get; set; } = "";
|
||||
public Guid RegionId { get; set; }
|
||||
public string RegionName { get; set; } = "";
|
||||
public Guid RepId { get; set; }
|
||||
}
|
||||
|
||||
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
|
||||
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
|
||||
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q =
|
||||
from li in db.InvoiceItems.AsNoTracking()
|
||||
where li.IsActive && !li.IsRemoved
|
||||
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
|
||||
select new SoldLine
|
||||
{
|
||||
Date = li.Invoice.FinalizedAt!.Value.Date,
|
||||
PoId = li.Invoice.FK_ShopPOId,
|
||||
Value = li.Quantity * li.UnitPrice,
|
||||
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
|
||||
CategoryId = li.Item.FK_CategoryId,
|
||||
CategoryName = li.Item.Category.Name,
|
||||
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
|
||||
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
|
||||
RepId = li.Invoice.ShopPO.RaisedById,
|
||||
};
|
||||
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
||||
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
||||
return q;
|
||||
}
|
||||
|
||||
private IQueryable<SaleReturnItem> ReturnLines(DashboardFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
|
||||
var q = db.SaleReturnItems.AsNoTracking()
|
||||
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
|
||||
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
|
||||
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
return q;
|
||||
}
|
||||
|
||||
public async Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f)
|
||||
{
|
||||
var lines = SoldLines(f);
|
||||
var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||
var orders = await lines.Select(l => l.PoId).Distinct().CountAsync();
|
||||
var costed = lines.Where(l => l.Cost != null);
|
||||
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
||||
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
||||
return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
|
||||
}
|
||||
|
||||
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
|
||||
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
|
||||
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
|
||||
{
|
||||
if (f.CategoryId.HasValue) return 0m;
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q = db.Invoices.AsNoTracking()
|
||||
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||
}
|
||||
|
||||
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
|
||||
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
|
||||
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
|
||||
private const int OverdueDays = 30;
|
||||
|
||||
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
|
||||
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
|
||||
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue)
|
||||
{
|
||||
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
}
|
||||
|
||||
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||
|
||||
var rows = await invs.Select(i => new
|
||||
{
|
||||
i.FinalizedAt,
|
||||
i.TotalAmount,
|
||||
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||
.Sum(p => (decimal?)p.Amount) ?? 0m,
|
||||
}).ToListAsync();
|
||||
|
||||
var balances = rows
|
||||
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
|
||||
.ToList();
|
||||
return new ReceivablesTotals(collected,
|
||||
balances.Sum(b => b.Balance),
|
||||
balances.Where(b => b.Due < end).Sum(b => b.Balance));
|
||||
}
|
||||
|
||||
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||
(await SoldLines(f).GroupBy(l => l.Date)
|
||||
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) })
|
||||
.ToListAsync())
|
||||
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
|
||||
|
||||
public async Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f) =>
|
||||
(await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date)
|
||||
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) })
|
||||
.ToListAsync())
|
||||
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
|
||||
|
||||
public async Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f) =>
|
||||
(await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName })
|
||||
.Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) })
|
||||
.OrderByDescending(x => x.Amount).ToListAsync())
|
||||
.Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList();
|
||||
|
||||
public async Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f) =>
|
||||
(await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName })
|
||||
.Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) })
|
||||
.OrderByDescending(x => x.Amount).ToListAsync())
|
||||
.Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList();
|
||||
|
||||
public async Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f)
|
||||
{
|
||||
var rows = await SoldLines(f).GroupBy(l => l.RepId)
|
||||
.Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) })
|
||||
.OrderByDescending(x => x.Amount).Take(20).ToListAsync();
|
||||
var ids = rows.Select(r => r.RepId).ToList();
|
||||
var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, u => u.FullName);
|
||||
return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList();
|
||||
}
|
||||
|
||||
private IQueryable<StockBalance> Balances(DashboardFilter f)
|
||||
{
|
||||
var q = db.StockBalances.AsNoTracking().AsQueryable();
|
||||
if (f.WarehouseId.HasValue)
|
||||
q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
|
||||
if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
return q;
|
||||
}
|
||||
|
||||
// Value = quantity x cost price; items without a cost price contribute quantity only.
|
||||
public async Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f) =>
|
||||
(await Balances(f).GroupBy(b => b.Item.Category.Name)
|
||||
.Select(g => new
|
||||
{
|
||||
Category = g.Key,
|
||||
Quantity = g.Sum(x => x.Quantity),
|
||||
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
|
||||
})
|
||||
.OrderByDescending(x => x.Value).ToListAsync())
|
||||
.Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList();
|
||||
|
||||
public async Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f)
|
||||
{
|
||||
var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse)
|
||||
.GroupBy(b => b.LocationId)
|
||||
.Select(g => new
|
||||
{
|
||||
Id = g.Key,
|
||||
Quantity = g.Sum(x => x.Quantity),
|
||||
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
|
||||
})
|
||||
.ToListAsync();
|
||||
var ids = rows.Select(r => r.Id).ToList();
|
||||
var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id))
|
||||
.ToDictionaryAsync(w => w.Id, w => w.Name);
|
||||
return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value))
|
||||
.OrderByDescending(r => r.Value).ToList();
|
||||
}
|
||||
|
||||
// Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages.
|
||||
// Transfers, loads and adjustments are internal/signed and excluded from this trend.
|
||||
public async Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q = db.StockMovements.AsNoTracking()
|
||||
.Where(m => m.CreatedAt >= start && m.CreatedAt < end
|
||||
&& (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn
|
||||
|| m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage));
|
||||
if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.WarehouseId.HasValue)
|
||||
q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value);
|
||||
|
||||
var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type })
|
||||
.Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) })
|
||||
.ToListAsync();
|
||||
return rows.GroupBy(r => r.Date)
|
||||
.Select(g => (g.Key,
|
||||
g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty),
|
||||
g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty)))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace Distribution.Features.DashboardReport;
|
||||
|
||||
public class DashboardReportService(IDashboardReportRepository repo)
|
||||
{
|
||||
public const int MaxRangeDays = 366;
|
||||
private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
|
||||
|
||||
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
||||
{
|
||||
var end = (to ?? DateTime.Now).Date;
|
||||
var start = (from ?? end.AddDays(-29)).Date;
|
||||
return new DashboardFilter(start, end, regionId, categoryId, warehouseId);
|
||||
}
|
||||
|
||||
public static string? Validate(DashboardFilter f)
|
||||
{
|
||||
if (f.To < f.From) return "'to' must not be before 'from'.";
|
||||
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
|
||||
|
||||
private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM");
|
||||
|
||||
private static SalesKpis ToKpis(SalesTotals t)
|
||||
{
|
||||
var net = t.GrossSales - t.ReturnValue - t.Discounts;
|
||||
var gp = t.CostedSales - t.CostOfSales;
|
||||
return new SalesKpis(
|
||||
t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
|
||||
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
||||
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
||||
}
|
||||
|
||||
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
|
||||
new(r.Collected, r.Outstanding, r.Overdue,
|
||||
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
|
||||
|
||||
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||
{
|
||||
var days = (f.To - f.From).Days + 1;
|
||||
var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) };
|
||||
var daily = Daily(f);
|
||||
|
||||
var current = await repo.GetSalesTotalsAsync(f);
|
||||
var previous = await repo.GetSalesTotalsAsync(prev);
|
||||
var currentCash = await repo.GetReceivablesAsync(f);
|
||||
var previousCash = await repo.GetReceivablesAsync(prev);
|
||||
var sales = await repo.GetDailySalesAsync(f);
|
||||
var returns = await repo.GetDailyReturnsAsync(f);
|
||||
|
||||
var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m))
|
||||
.Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount)))
|
||||
.GroupBy(x => Bucket(x.Date, daily))
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns)))
|
||||
.ToList();
|
||||
|
||||
static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount);
|
||||
return new SalesSummaryResponse(
|
||||
f.From, f.To, daily ? "day" : "month",
|
||||
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
||||
new CashKpiComparison(
|
||||
ToCash(currentCash, current.GrossSales - current.Discounts),
|
||||
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
|
||||
trend,
|
||||
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
||||
(await repo.GetSalesByRepAsync(f)).Select(Map).ToList());
|
||||
}
|
||||
|
||||
public async Task<InventorySummaryResponse> GetInventorySummaryAsync(DashboardFilter f)
|
||||
{
|
||||
var daily = Daily(f);
|
||||
var movements = (await repo.GetDailyMovementsAsync(f))
|
||||
.GroupBy(m => Bucket(m.Date, daily))
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g =>
|
||||
{
|
||||
var rec = g.Sum(x => x.Received);
|
||||
var iss = g.Sum(x => x.Issued);
|
||||
return new MovementPoint(g.Key, rec, iss, rec - iss);
|
||||
}).ToList();
|
||||
|
||||
return new InventorySummaryResponse(
|
||||
f.From, f.To, daily ? "day" : "month",
|
||||
await repo.GetStockValueByCategoryAsync(f),
|
||||
await repo.GetStockByWarehouseAsync(f),
|
||||
movements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Distribution.Features.DashboardReport;
|
||||
|
||||
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
||||
|
||||
public interface IDashboardReportRepository
|
||||
{
|
||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
|
||||
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
|
||||
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
|
||||
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
@@ -9,6 +10,7 @@ namespace Distribution.Features.DeliveryRide;
|
||||
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
||||
[Authorize("delivery-task.manage")]
|
||||
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -22,6 +24,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
|
||||
|
||||
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
||||
[Authorize("delivery-task.view")]
|
||||
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||
{
|
||||
var result = await service.GetByRideIdAsync(rideId);
|
||||
@@ -29,6 +32,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpGet("delivery-tasks")]
|
||||
//[Authorize("delivery-task.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] DeliveryTaskStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
@@ -39,6 +43,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpGet("delivery-tasks/{id:guid}")]
|
||||
[Authorize("delivery-task.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -46,6 +51,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPut("delivery-tasks/{id:guid}/quantities")]
|
||||
[Authorize("delivery-task.manage")]
|
||||
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -58,6 +64,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost("delivery-tasks/{id:guid}/confirm")]
|
||||
[Authorize("delivery-task.manage")]
|
||||
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -70,6 +77,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost("delivery-tasks/{id:guid}/fail")]
|
||||
[Authorize("delivery-task.manage")]
|
||||
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.LoadingEvent;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
|
||||
IShopPORepository shopPORepo,
|
||||
ILoadingSheetRepository loadingSheetRepo,
|
||||
StockMover mover,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string DeliveriesLink = "/operations/deliveries";
|
||||
|
||||
// Route owner + managers whose scope covers the store being delivered to.
|
||||
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
|
||||
[
|
||||
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
|
||||
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
|
||||
];
|
||||
|
||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||
new(t.Id,
|
||||
t.FK_RideId,
|
||||
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
if (!fullyDelivered && shopPO.Store is not null)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.DeliveryTaskPartiallyDelivered,
|
||||
DeliveryRecipients(shopPO.FK_StoreId),
|
||||
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
|
||||
ct);
|
||||
|
||||
var saved = await repo.GetByIdAsync(taskId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
if (task.ShopPO?.Store is not null)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.DeliveryTaskFailed,
|
||||
DeliveryRecipients(task.ShopPO.FK_StoreId),
|
||||
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
|
||||
ct);
|
||||
|
||||
return ToResponse(task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DriverProfile;
|
||||
@@ -12,6 +13,7 @@ public class DriverProfileController(
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("driver.view")]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(page, pageSize);
|
||||
@@ -19,6 +21,7 @@ public class DriverProfileController(
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("driver.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -26,6 +29,7 @@ public class DriverProfileController(
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("driver.manage")]
|
||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -44,6 +48,7 @@ public class DriverProfileController(
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("driver.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -51,6 +56,7 @@ public class DriverProfileController(
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize("driver.manage")]
|
||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var success = await service.DeactivateAsync(id, ct);
|
||||
@@ -58,6 +64,7 @@ public class DriverProfileController(
|
||||
}
|
||||
|
||||
[HttpGet("compliance")]
|
||||
[Authorize("driver.view")]
|
||||
public async Task<IActionResult> GetCompliance()
|
||||
{
|
||||
var result = await complianceService.GetAsync();
|
||||
@@ -65,6 +72,7 @@ public class DriverProfileController(
|
||||
}
|
||||
|
||||
[HttpPut("compliance")]
|
||||
[Authorize("driver.manage")]
|
||||
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Grn;
|
||||
@@ -7,6 +8,7 @@ namespace Distribution.Features.Grn;
|
||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/goods-receipts")]
|
||||
[Authorize("grn.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? purchaseOrderId,
|
||||
[FromQuery] int page = 1,
|
||||
@@ -17,6 +19,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
||||
}
|
||||
|
||||
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||
[Authorize("grn.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -24,6 +27,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
||||
}
|
||||
|
||||
[HttpPost("api/goods-receipts")]
|
||||
[Authorize("grn.manage")]
|
||||
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
@@ -42,6 +46,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
||||
|
||||
/// <summary>Goods received without a purchase order.</summary>
|
||||
[HttpPost("api/goods-receipts/direct")]
|
||||
[Authorize("grn.manage")]
|
||||
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
||||
@@ -55,10 +60,12 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||
[Authorize("grn.view")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||
[Authorize("grn.manage")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
||||
|
||||
@@ -9,6 +9,12 @@ public interface IInvoiceRepository
|
||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||
Task AddAsync(Models.Invoice invoice);
|
||||
Task<Models.Item?> GetItemAsync(Guid itemId);
|
||||
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
|
||||
Task AddVersionAsync(InvoiceVersion version);
|
||||
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
|
||||
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
|
||||
void Update(Models.Invoice invoice);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,52 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/preview-totals")]
|
||||
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var result = await service.PreviewTotalsAsync(id, request);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/versions")]
|
||||
public async Task<IActionResult> GetVersions(Guid id)
|
||||
{
|
||||
var result = await service.GetVersionsAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items")]
|
||||
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.EditedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/print")]
|
||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Invoice;
|
||||
|
||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
||||
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
|
||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
|
||||
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
|
||||
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
|
||||
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
|
||||
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
// Quantity = how many free units the scheme still entitles for the lines as typed.
|
||||
public record FreeEntitlement(Guid SchemeId, int Quantity);
|
||||
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
|
||||
|
||||
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
|
||||
|
||||
public record InvoiceVersionResponse(
|
||||
Guid Id,
|
||||
int VersionNumber,
|
||||
InvoiceVersionStage Stage,
|
||||
decimal TotalAmount,
|
||||
Guid CreatedBy,
|
||||
DateTime CreatedAt,
|
||||
List<InvoiceVersionItemResponse> Items);
|
||||
|
||||
public record InvoiceItemResponse(
|
||||
Guid Id,
|
||||
Guid? ShopPOItemId,
|
||||
Guid ItemId,
|
||||
string ItemName,
|
||||
int? OrderedQuantity,
|
||||
int Quantity,
|
||||
decimal UnitPrice,
|
||||
bool IsRemoved,
|
||||
bool IsFree = false,
|
||||
Guid? SchemeId = null,
|
||||
string? SchemeName = null);
|
||||
|
||||
public record InvoiceResponse(
|
||||
Guid Id,
|
||||
Guid ShopPOId,
|
||||
Guid StoreId,
|
||||
string StoreName,
|
||||
InvoiceStatus Status,
|
||||
PaymentStatus PaymentStatus,
|
||||
@@ -19,7 +56,11 @@ public record InvoiceResponse(
|
||||
DateTime? FinalizedAt,
|
||||
Guid? FinalizedBy,
|
||||
decimal TotalAmount,
|
||||
decimal DiscountAmount,
|
||||
decimal PaidAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
DateTime CreatedAt,
|
||||
List<InvoiceItemResponse> Items,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
|
||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||
|
||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
|
||||
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
|
||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||
|
||||
@@ -21,6 +23,7 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||
.Where(i => i.IsActive
|
||||
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
||||
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
|
||||
@@ -48,9 +51,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
|
||||
await context.SchemeRedemptions
|
||||
.Where(r => r.FK_ShopPOId == shopPOId)
|
||||
.SumAsync(r => r.DiscountAmount) ?? 0m;
|
||||
|
||||
public async Task AddAsync(Models.Invoice invoice) =>
|
||||
await context.Invoices.AddAsync(invoice);
|
||||
|
||||
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
|
||||
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
|
||||
|
||||
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
|
||||
await context.InvoiceItems.AddRangeAsync(items);
|
||||
|
||||
public async Task AddVersionAsync(InvoiceVersion version) =>
|
||||
await context.InvoiceVersions.AddAsync(version);
|
||||
|
||||
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
|
||||
await context.InvoiceVersions
|
||||
.Where(v => v.FK_InvoiceId == invoiceId)
|
||||
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
|
||||
|
||||
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
|
||||
await context.InvoiceVersions
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
|
||||
.OrderBy(v => v.VersionNumber)
|
||||
.ToListAsync();
|
||||
|
||||
public void Update(Models.Invoice invoice) =>
|
||||
context.Invoices.Update(invoice);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -7,21 +9,126 @@ namespace Distribution.Features.Invoice;
|
||||
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IShopPORepository shopPoRepo,
|
||||
SchemeService schemeService,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||
new(i.Id, i.FK_ShopPOId,
|
||||
i.ShopPO?.FK_StoreId ?? Guid.Empty,
|
||||
i.ShopPO?.Store?.StoreName ?? string.Empty,
|
||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||
i.PrintedAt, i.PrintedBy,
|
||||
i.FinalizedAt, i.FinalizedBy,
|
||||
i.TotalAmount, i.IsActive, i.CreatedAt);
|
||||
i.TotalAmount, i.DiscountAmount,
|
||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
||||
i.IsActive, i.CreatedAt,
|
||||
i.Items.Where(x => x.IsActive).Select(x =>
|
||||
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
||||
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
||||
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
|
||||
|
||||
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||
|
||||
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
|
||||
// scheme rules ignore them.
|
||||
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved)
|
||||
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
|
||||
.ToList();
|
||||
|
||||
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
|
||||
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
|
||||
// and at print/finalize. The redemption amount is updated with it.
|
||||
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||
{
|
||||
var lines = items.ToList();
|
||||
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
|
||||
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
|
||||
var billable = BillableTotal(lines);
|
||||
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
|
||||
invoice.DiscountAmount = Math.Min(discount, billable);
|
||||
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||
}
|
||||
|
||||
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
|
||||
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
if (invoice is null) return null;
|
||||
|
||||
var lines = request.Items.Where(l => !l.IsRemoved)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
|
||||
.ToList();
|
||||
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
|
||||
var chosen = request.DiscountSchemeIds is null
|
||||
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
|
||||
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
|
||||
var discount = Math.Min(chosen, billable);
|
||||
|
||||
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
|
||||
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
|
||||
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
|
||||
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
|
||||
}
|
||||
|
||||
// Immutable copy of the invoice lines as they stand right now.
|
||||
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
||||
new()
|
||||
{
|
||||
FK_InvoiceId = invoice.Id,
|
||||
VersionNumber = versionNumber,
|
||||
Stage = stage,
|
||||
TotalAmount = invoice.TotalAmount,
|
||||
CreatedBy = userId,
|
||||
Items = items.Select(i => new InvoiceVersionItem
|
||||
{
|
||||
FK_ShopPOItemId = i.FK_ShopPOItemId,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
IsRemoved = i.IsRemoved
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
|
||||
{
|
||||
if (await repo.GetByIdAsync(id) is null) return null;
|
||||
var versions = await repo.GetVersionsAsync(id);
|
||||
return versions.Select(v => new InvoiceVersionResponse(
|
||||
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
|
||||
v.Items.Select(i => new InvoiceVersionItemResponse(
|
||||
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
|
||||
}
|
||||
|
||||
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
|
||||
{
|
||||
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
|
||||
// Added lines may or may not be in invoice.Items yet, so merge by reference.
|
||||
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
|
||||
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
|
||||
}
|
||||
|
||||
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
|
||||
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
|
||||
{
|
||||
FK_InvoiceId = invoiceId,
|
||||
FK_ShopPOItemId = i.Id,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
IsFree = i.IsFree,
|
||||
FK_SchemeId = i.FK_SchemeId,
|
||||
SchemeName = i.SchemeName
|
||||
}).ToList();
|
||||
|
||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
return invoice is null ? null : ToResponse(invoice);
|
||||
if (invoice is null) return null;
|
||||
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
||||
@@ -39,6 +146,131 @@ public class InvoiceService(
|
||||
return new OutstandingPaymentsResponse(count, outstanding);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
||||
{
|
||||
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.ConfirmedAt is null)
|
||||
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
||||
if (po.InvoiceId is not null)
|
||||
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
||||
|
||||
var invoice = new Models.Invoice
|
||||
{
|
||||
FK_ShopPOId = po.Id,
|
||||
Status = InvoiceStatus.Draft,
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||
await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(invoice);
|
||||
await repo.AddItemsAsync(lines);
|
||||
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
||||
po.InvoiceId = invoice.Id;
|
||||
shopPoRepo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(invoice.Id);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Invoice not found");
|
||||
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
|
||||
|
||||
var newLines = new List<InvoiceItem>();
|
||||
|
||||
foreach (var edit in request.Items)
|
||||
{
|
||||
if (edit.UnitPrice is < 0)
|
||||
throw new InvalidOperationException("Unit price cannot be negative");
|
||||
|
||||
if (edit.InvoiceItemId is Guid lineId)
|
||||
{
|
||||
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
|
||||
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
|
||||
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
|
||||
|
||||
item.IsRemoved = edit.IsRemoved;
|
||||
if (!edit.IsRemoved)
|
||||
{
|
||||
ValidateQuantity(item, edit.Quantity);
|
||||
item.Quantity = edit.Quantity;
|
||||
}
|
||||
if (edit.UnitPrice is decimal price)
|
||||
{
|
||||
if (item.IsFree && price != 0m)
|
||||
throw new InvalidOperationException("A free-issue line must stay at zero price");
|
||||
item.UnitPrice = price;
|
||||
}
|
||||
}
|
||||
else if (edit.ItemId is Guid itemId)
|
||||
{
|
||||
// New line for an item that was not on the PO.
|
||||
if (edit.IsRemoved) continue;
|
||||
if (edit.Quantity <= 0)
|
||||
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
|
||||
if (edit.UnitPrice is null)
|
||||
throw new InvalidOperationException("Unit price is required for an added item");
|
||||
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
|
||||
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
|
||||
|
||||
var catalogItem = await repo.GetItemAsync(itemId)
|
||||
?? throw new InvalidOperationException($"Item {itemId} not found");
|
||||
|
||||
newLines.Add(new InvoiceItem
|
||||
{
|
||||
FK_InvoiceId = invoice.Id,
|
||||
FK_ItemId = catalogItem.Id,
|
||||
Item = catalogItem,
|
||||
Quantity = edit.Quantity,
|
||||
UnitPrice = edit.UnitPrice.Value
|
||||
});
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||
}
|
||||
|
||||
var allLines = invoice.Items.Concat(newLines).ToList();
|
||||
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
||||
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
||||
await ApplyTotalsAsync(invoice, allLines);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(id))!;
|
||||
}
|
||||
|
||||
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||
{
|
||||
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
|
||||
if (quantity < 0 || quantity > ordered)
|
||||
throw new InvalidOperationException(
|
||||
$"Quantity for item {item.Id} must be between 0 and {ordered}");
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
@@ -47,12 +279,21 @@ public class InvoiceService(
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||
|
||||
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
||||
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
||||
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
invoice.Status = InvoiceStatus.Printed;
|
||||
invoice.PrintedAt = DateTime.Now;
|
||||
invoice.PrintedBy = printedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(invoice);
|
||||
@@ -66,31 +307,37 @@ public class InvoiceService(
|
||||
if (invoice.Status != InvoiceStatus.Printed)
|
||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||
|
||||
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||
var adjustments = request.Adjustments ?? [];
|
||||
if (adjustments.Count > 0)
|
||||
|
||||
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
||||
foreach (var adjustment in adjustments)
|
||||
{
|
||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
||||
decimal total = 0;
|
||||
foreach (var item in poItems)
|
||||
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||
|
||||
item.IsRemoved = adjustment.IsRemoved;
|
||||
if (!adjustment.IsRemoved)
|
||||
{
|
||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
||||
|
||||
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
||||
throw new InvalidOperationException(
|
||||
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
||||
|
||||
total += billedQuantity * item.UnitPrice;
|
||||
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||
item.Quantity = adjustment.AdjustedQuantity;
|
||||
}
|
||||
invoice.TotalAmount = total;
|
||||
}
|
||||
|
||||
if (items.Count > 0)
|
||||
await ApplyTotalsAsync(invoice, items);
|
||||
|
||||
invoice.Status = InvoiceStatus.Finalized;
|
||||
invoice.FinalizedAt = DateTime.Now;
|
||||
invoice.FinalizedBy = finalizedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(invoice);
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.LoadingEvent;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/loading-sheets")]
|
||||
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
||||
public class LoadingSheetController(
|
||||
LoadingSheetService service,
|
||||
IScopeAccessService scopeAccess,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
//[Authorize("loading-sheet.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? rideId,
|
||||
[FromQuery] LoadingSheetStatus? status,
|
||||
@@ -17,11 +22,26 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
if (routeId.HasValue)
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, routeId.Value))
|
||||
return StatusCode(403);
|
||||
}
|
||||
|
||||
if (regionId.HasValue)
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
|
||||
return StatusCode(403);
|
||||
}
|
||||
|
||||
var result = await service.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("loading-sheet.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -29,6 +49,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("loading-sheet.manage")]
|
||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
@@ -45,6 +66,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items")]
|
||||
[Authorize("loading-sheet.manage")]
|
||||
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -57,6 +79,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm")]
|
||||
[Authorize("loading-sheet.manage")]
|
||||
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
public interface INotificationRepository
|
||||
{
|
||||
// ── Inbox ────────────────────────────────────────────────────────────
|
||||
Task AddAsync(Models.Notification notification);
|
||||
/// <summary>Stops tracking a notification (and its recipients) whose save failed.</summary>
|
||||
void Discard(Models.Notification notification);
|
||||
|
||||
Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly);
|
||||
Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize);
|
||||
Task<int> GetUnreadCountAsync(Guid userId);
|
||||
Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId);
|
||||
Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId);
|
||||
void RemoveRecipient(Models.NotificationRecipient recipient);
|
||||
|
||||
// ── Recipient resolution ─────────────────────────────────────────────
|
||||
Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds);
|
||||
Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode);
|
||||
Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id);
|
||||
Task<Guid?> GetReportsToUserIdAsync(Guid userId);
|
||||
Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id);
|
||||
|
||||
// ── Retention ────────────────────────────────────────────────────────
|
||||
Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take);
|
||||
Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take);
|
||||
void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients);
|
||||
void RemoveNotifications(IEnumerable<Models.Notification> notifications);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Daily retention purge: deletes read inbox rows older than <c>Notifications:RetentionDays</c>
|
||||
/// (default 90), then any notification left with no recipients.
|
||||
/// </summary>
|
||||
public class NotificationCleanupJob(
|
||||
IServiceScopeFactory scopes,
|
||||
IConfiguration config,
|
||||
ILogger<NotificationCleanupJob> logger) : BackgroundService
|
||||
{
|
||||
private const int BatchSize = 500;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Let the app finish starting before the first run.
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await RunOnceAsync(stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { logger.LogError(ex, "Notification cleanup failed."); }
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var retentionDays = config.GetValue("Notifications:RetentionDays", 90);
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
|
||||
using var scope = scopes.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||
var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||
|
||||
var removed = 0;
|
||||
while (true)
|
||||
{
|
||||
var expired = await repo.GetExpiredReadRecipientsAsync(cutoff, BatchSize);
|
||||
if (expired.Count == 0) break;
|
||||
|
||||
repo.RemoveRecipients(expired);
|
||||
await uow.CommitAsync(ct);
|
||||
removed += expired.Count;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
var orphans = await repo.GetOrphanNotificationsAsync(BatchSize);
|
||||
if (orphans.Count == 0) break;
|
||||
|
||||
repo.RemoveNotifications(orphans);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
|
||||
if (removed > 0)
|
||||
logger.LogInformation("Notification cleanup removed {Count} read inbox rows older than {Days} days.", removed, retentionDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
/// <summary>Every endpoint acts on the signed-in user's own notifications only.</summary>
|
||||
[ApiController]
|
||||
[Route("api/notifications")]
|
||||
public class NotificationController(NotificationService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetInbox(
|
||||
[FromQuery] bool unreadOnly = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
return Ok(await service.GetInboxAsync(userId, unreadOnly, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("unread-count")]
|
||||
public async Task<IActionResult> GetUnreadCount()
|
||||
{
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
return Ok(await service.GetUnreadCountAsync(userId));
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/read")]
|
||||
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
try
|
||||
{
|
||||
await service.MarkReadAsync(userId, id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("read-all")]
|
||||
public async Task<IActionResult> MarkAllRead(CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
await service.MarkAllReadAsync(userId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
try
|
||||
{
|
||||
await service.DeleteAsync(userId, id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
/// <param name="Values">Object whose public properties fill the {Token} placeholders in the type's templates.</param>
|
||||
/// <param name="CreatedByUserId">The acting user; excluded from the recipients.</param>
|
||||
public record NotificationArgs(
|
||||
object? Values,
|
||||
string EntityType,
|
||||
Guid EntityId,
|
||||
string Link,
|
||||
Guid? CreatedByUserId = null);
|
||||
|
||||
/// <param name="Id">The caller's own recipient row id — use it for read / delete.</param>
|
||||
public record NotificationResponse(
|
||||
Guid Id,
|
||||
string Type,
|
||||
NotificationSeverity Severity,
|
||||
string Title,
|
||||
string Message,
|
||||
string EntityType,
|
||||
Guid EntityId,
|
||||
string Link,
|
||||
DateTime CreatedAt,
|
||||
bool IsRead,
|
||||
DateTime? ReadAt);
|
||||
|
||||
public record UnreadCountResponse(int Count);
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Server → client only. The server pushes a "notification" event to each recipient; clients never
|
||||
/// invoke hub methods. Users are addressed by their id (see <see cref="NotificationUserIdProvider"/>).
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class NotificationHub : Hub
|
||||
{
|
||||
public const string Path = "/hubs/notifications";
|
||||
public const string ClientEvent = "notification";
|
||||
}
|
||||
|
||||
/// <summary>Maps a SignalR connection to the "UserId" claim that <c>AuthMiddleware</c> puts on the principal.</summary>
|
||||
public class NotificationUserIdProvider : IUserIdProvider
|
||||
{
|
||||
public string? GetUserId(HubConnectionContext connection) =>
|
||||
connection.User?.FindFirst("UserId")?.Value;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
public class NotificationRecipientResolver(INotificationRepository repo)
|
||||
{
|
||||
/// <summary>
|
||||
/// Expands the rules into a de-duplicated list of active user ids, never including
|
||||
/// <paramref name="actingUserId"/>.
|
||||
/// </summary>
|
||||
public async Task<List<Guid>> ResolveAsync(IEnumerable<RecipientRule> rules, Guid? actingUserId)
|
||||
{
|
||||
var ids = new HashSet<Guid>();
|
||||
|
||||
foreach (var rule in rules)
|
||||
ids.UnionWith(await ResolveRuleAsync(rule));
|
||||
|
||||
if (actingUserId is { } actor)
|
||||
ids.Remove(actor);
|
||||
|
||||
if (ids.Count == 0) return [];
|
||||
return await repo.FilterActiveUserIdsAsync(ids);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Guid>> ResolveRuleAsync(RecipientRule rule)
|
||||
{
|
||||
switch (rule)
|
||||
{
|
||||
case UsersRule r:
|
||||
return r.Ids;
|
||||
|
||||
case PermissionRule r:
|
||||
return await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||
|
||||
case ScopeRule r:
|
||||
return await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||
|
||||
case PermissionInScopeRule r:
|
||||
var withPermission = await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||
var inScope = await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||
return withPermission.Intersect(inScope);
|
||||
|
||||
case ReportsToRule r:
|
||||
var supervisor = await repo.GetReportsToUserIdAsync(r.UserId);
|
||||
return supervisor is { } s ? [s] : [];
|
||||
|
||||
case RouteOwnerRule r:
|
||||
var owner = await repo.GetRouteOwnerUserIdAsync(r.Kind, r.Id);
|
||||
return owner is { } o ? [o] : [];
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Unknown recipient rule {rule.GetType().Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
public class NotificationRepository(AppDBContext context) : INotificationRepository
|
||||
{
|
||||
// ── Inbox ────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task AddAsync(Models.Notification notification) =>
|
||||
await context.Notifications.AddAsync(notification);
|
||||
|
||||
public void Discard(Models.Notification notification)
|
||||
{
|
||||
foreach (var recipient in notification.Recipients)
|
||||
context.Entry(recipient).State = EntityState.Detached;
|
||||
context.Entry(notification).State = EntityState.Detached;
|
||||
}
|
||||
|
||||
private IQueryable<Models.NotificationRecipient> InboxQuery(Guid userId, bool unreadOnly)
|
||||
{
|
||||
var query = context.NotificationRecipients.Where(r => r.FK_UserId == userId && r.IsActive);
|
||||
return unreadOnly ? query.Where(r => !r.IsRead) : query;
|
||||
}
|
||||
|
||||
public Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly) =>
|
||||
InboxQuery(userId, unreadOnly).CountAsync();
|
||||
|
||||
public Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize) =>
|
||||
InboxQuery(userId, unreadOnly)
|
||||
.Include(r => r.Notification)
|
||||
.OrderByDescending(r => r.Notification.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
public Task<int> GetUnreadCountAsync(Guid userId) =>
|
||||
InboxQuery(userId, unreadOnly: true).CountAsync();
|
||||
|
||||
public Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId) =>
|
||||
context.NotificationRecipients
|
||||
.FirstOrDefaultAsync(r => r.Id == recipientId && r.FK_UserId == userId && r.IsActive);
|
||||
|
||||
public Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId) =>
|
||||
InboxQuery(userId, unreadOnly: true).ToListAsync();
|
||||
|
||||
public void RemoveRecipient(Models.NotificationRecipient recipient) =>
|
||||
context.NotificationRecipients.Remove(recipient);
|
||||
|
||||
// ── Recipient resolution ─────────────────────────────────────────────
|
||||
|
||||
public Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds)
|
||||
{
|
||||
var ids = userIds.ToList();
|
||||
return context.Users
|
||||
.Where(u => u.IsActive && ids.Contains(u.Id))
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode) =>
|
||||
context.Users
|
||||
.Where(u => u.IsActive && context.RolePermissions.Any(rp =>
|
||||
rp.FK_RoleId == u.FK_RoleId && rp.IsActive &&
|
||||
rp.Permission.IsActive && rp.Permission.Code == permissionCode))
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id)
|
||||
{
|
||||
Guid? regionId = null;
|
||||
Guid? routeId = null;
|
||||
Guid? storeId = null;
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case ScopeKind.Region:
|
||||
regionId = id;
|
||||
break;
|
||||
case ScopeKind.Route:
|
||||
routeId = id;
|
||||
regionId = await context.Routes.Where(r => r.Id == id)
|
||||
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||
break;
|
||||
case ScopeKind.Store:
|
||||
storeId = id;
|
||||
routeId = await context.Stores.Where(s => s.Id == id)
|
||||
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync();
|
||||
if (routeId is { } rid)
|
||||
regionId = await context.Routes.Where(r => r.Id == rid)
|
||||
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||
break;
|
||||
}
|
||||
|
||||
var userIds = new HashSet<Guid>();
|
||||
|
||||
if (regionId is { } region)
|
||||
userIds.UnionWith(await context.UserRegionScopes
|
||||
.Where(s => s.IsActive && s.FK_RegionId == region)
|
||||
.Select(s => s.FK_UserId).ToListAsync());
|
||||
|
||||
if (routeId is { } route)
|
||||
userIds.UnionWith(await context.UserRouteScopes
|
||||
.Where(s => s.IsActive && s.FK_RouteId == route)
|
||||
.Select(s => s.FK_UserId).ToListAsync());
|
||||
|
||||
if (storeId is { } store)
|
||||
userIds.UnionWith(await context.UserStoreScopes
|
||||
.Where(s => s.IsActive && s.FK_StoreId == store)
|
||||
.Select(s => s.FK_UserId).ToListAsync());
|
||||
|
||||
return userIds.ToList();
|
||||
}
|
||||
|
||||
public Task<Guid?> GetReportsToUserIdAsync(Guid userId) =>
|
||||
context.Users.Where(u => u.Id == userId)
|
||||
.Select(u => u.FK_ReportsToUserId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id)
|
||||
{
|
||||
Guid? routeId = kind switch
|
||||
{
|
||||
ScopeKind.Route => id,
|
||||
ScopeKind.Store => await context.Stores.Where(s => s.Id == id)
|
||||
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync(),
|
||||
_ => null
|
||||
};
|
||||
if (routeId is not { } rid) return null;
|
||||
|
||||
return await context.Routes.Where(r => r.Id == rid && r.IsActive)
|
||||
.Select(r => r.FK_OwnerUserId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
// ── Retention ────────────────────────────────────────────────────────
|
||||
|
||||
public Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take) =>
|
||||
context.NotificationRecipients
|
||||
.Where(r => r.IsRead && r.ReadAt != null && r.ReadAt < cutoff)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take) =>
|
||||
context.Notifications
|
||||
.Where(n => !n.Recipients.Any())
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
public void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients) =>
|
||||
context.NotificationRecipients.RemoveRange(recipients);
|
||||
|
||||
public void RemoveNotifications(IEnumerable<Models.Notification> notifications) =>
|
||||
context.Notifications.RemoveRange(notifications);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a notification for every resolved recipient and pushes it live. Best-effort: this method
|
||||
/// never throws, so a notification problem can never fail the business action that triggered it.
|
||||
/// Call it only AFTER the owning service's <c>uow.CommitAsync()</c> has succeeded.
|
||||
/// </summary>
|
||||
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class NotificationService(
|
||||
INotificationRepository repo,
|
||||
NotificationRecipientResolver resolver,
|
||||
IHubContext<NotificationHub> hub,
|
||||
IUnitOfWork uow,
|
||||
ILogger<NotificationService> logger) : INotificationService
|
||||
{
|
||||
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
|
||||
|
||||
// ── Trigger side ─────────────────────────────────────────────────────
|
||||
|
||||
public async Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!NotificationTypes.All.TryGetValue(type, out var definition))
|
||||
{
|
||||
logger.LogError("Notification type {Type} is not registered in NotificationTypes; skipped.", type);
|
||||
return;
|
||||
}
|
||||
|
||||
var userIds = await resolver.ResolveAsync(recipients, args.CreatedByUserId);
|
||||
if (userIds.Count == 0) return;
|
||||
|
||||
var values = ToDictionary(args.Values);
|
||||
var notification = new Models.Notification
|
||||
{
|
||||
Type = type,
|
||||
Severity = definition.Severity,
|
||||
Title = Truncate(Render(definition.TitleTemplate, values), 150),
|
||||
Message = Truncate(Render(definition.MessageTemplate, values), 500),
|
||||
EntityType = args.EntityType,
|
||||
EntityId = args.EntityId,
|
||||
Link = args.Link,
|
||||
CreatedByUserId = args.CreatedByUserId,
|
||||
CreatedAt = DateTime.Now,
|
||||
Recipients = userIds.Select(id => new Models.NotificationRecipient { FK_UserId = id }).ToList()
|
||||
};
|
||||
|
||||
await repo.AddAsync(notification);
|
||||
try
|
||||
{
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
repo.Discard(notification);
|
||||
throw;
|
||||
}
|
||||
|
||||
await PushAsync(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create {Type} notification for {EntityType} {EntityId}.",
|
||||
type, args.EntityType, args.EntityId);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate from persistence: a failed push still leaves the notification in the inbox.
|
||||
private async Task PushAsync(Models.Notification notification)
|
||||
{
|
||||
foreach (var recipient in notification.Recipients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await hub.Clients.User(recipient.FK_UserId.ToString())
|
||||
.SendAsync(NotificationHub.ClientEvent, ToResponse(notification, recipient));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to push notification {NotificationId} to user {UserId}.",
|
||||
notification.Id, recipient.FK_UserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inbox side ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<PagedResponse<NotificationResponse>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var total = await repo.GetInboxCountAsync(userId, unreadOnly);
|
||||
var rows = await repo.GetInboxAsync(userId, unreadOnly, page, pageSize);
|
||||
return new PagedResponse<NotificationResponse>(page, pageSize, total,
|
||||
rows.Select(r => ToResponse(r.Notification, r)).ToList());
|
||||
}
|
||||
|
||||
public async Task<UnreadCountResponse> GetUnreadCountAsync(Guid userId) =>
|
||||
new(await repo.GetUnreadCountAsync(userId));
|
||||
|
||||
public async Task MarkReadAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||
{
|
||||
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||
?? throw new KeyNotFoundException("Notification not found.");
|
||||
if (recipient.IsRead) return;
|
||||
|
||||
recipient.IsRead = true;
|
||||
recipient.ReadAt = DateTime.Now;
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
|
||||
public async Task MarkAllReadAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var unread = await repo.GetUnreadRecipientsAsync(userId);
|
||||
if (unread.Count == 0) return;
|
||||
|
||||
var now = DateTime.Now;
|
||||
foreach (var recipient in unread)
|
||||
{
|
||||
recipient.IsRead = true;
|
||||
recipient.ReadAt = now;
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||
{
|
||||
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||
?? throw new KeyNotFoundException("Notification not found.");
|
||||
|
||||
repo.RemoveRecipient(recipient);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static NotificationResponse ToResponse(Models.Notification n, Models.NotificationRecipient r) =>
|
||||
new(r.Id, n.Type, n.Severity, n.Title, n.Message, n.EntityType, n.EntityId, n.Link,
|
||||
n.CreatedAt, r.IsRead, r.ReadAt);
|
||||
|
||||
private static Dictionary<string, string> ToDictionary(object? values) =>
|
||||
values is null
|
||||
? []
|
||||
: values.GetType().GetProperties()
|
||||
.ToDictionary(p => p.Name, p => p.GetValue(values)?.ToString() ?? string.Empty);
|
||||
|
||||
private static string Render(string template, Dictionary<string, string> values) =>
|
||||
TokenPattern.Replace(template, m => values.TryGetValue(m.Groups[1].Value, out var v) ? v : string.Empty).Trim();
|
||||
|
||||
private static string Truncate(string value, int max) =>
|
||||
value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
public record NotificationTypeDefinition(NotificationSeverity Severity, string TitleTemplate, string MessageTemplate);
|
||||
|
||||
/// <summary>
|
||||
/// Registry of every notification type. Services must pass one of these constants to
|
||||
/// <see cref="INotificationService.NotifyAsync"/> — never a string literal. Templates use {Token}
|
||||
/// placeholders filled from <see cref="NotificationArgs.Values"/>.
|
||||
/// </summary>
|
||||
public static class NotificationTypes
|
||||
{
|
||||
public const string RideCancelled = "RideCancelled";
|
||||
public const string RideDriverAssigned = "RideDriverAssigned";
|
||||
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
|
||||
public const string DeliveryTaskPartiallyDelivered = "DeliveryTaskPartiallyDelivered";
|
||||
public const string SupplierClaimApproved = "SupplierClaimApproved";
|
||||
public const string SupplierClaimRejected = "SupplierClaimRejected";
|
||||
public const string ShopPOConfirmed = "ShopPOConfirmed";
|
||||
public const string ShopPOCancelled = "ShopPOCancelled";
|
||||
public const string StockLow = "StockLow";
|
||||
public const string CreditLimitBreached = "CreditLimitBreached";
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All =
|
||||
new Dictionary<string, NotificationTypeDefinition>
|
||||
{
|
||||
[RideCancelled] = new(NotificationSeverity.Critical,
|
||||
"Ride cancelled", "A ride was cancelled ({CancelType}). {Reason}"),
|
||||
[RideDriverAssigned] = new(NotificationSeverity.Info,
|
||||
"Ride assigned", "You have been assigned to a ride."),
|
||||
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
|
||||
"Delivery failed", "Delivery to {StoreName} failed. {Note}"),
|
||||
[DeliveryTaskPartiallyDelivered] = new(NotificationSeverity.Warning,
|
||||
"Partial delivery", "Delivery to {StoreName} was only partly delivered."),
|
||||
[SupplierClaimApproved] = new(NotificationSeverity.Success,
|
||||
"Supplier claim approved", "Your supplier claim was approved."),
|
||||
[SupplierClaimRejected] = new(NotificationSeverity.Warning,
|
||||
"Supplier claim rejected", "Your supplier claim was rejected."),
|
||||
[ShopPOConfirmed] = new(NotificationSeverity.Info,
|
||||
"Shop order confirmed", "Shop order for {StoreName} was confirmed."),
|
||||
[ShopPOCancelled] = new(NotificationSeverity.Warning,
|
||||
"Shop order cancelled", "Shop order for {StoreName} was cancelled: {Reason}"),
|
||||
[StockLow] = new(NotificationSeverity.Warning,
|
||||
"Low stock", "{ItemName} is down to {Quantity} at {Location}."),
|
||||
[CreditLimitBreached] = new(NotificationSeverity.Warning,
|
||||
"Credit limit exceeded", "{StoreName} has exceeded its credit limit."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Distribution.Features.Notifications;
|
||||
|
||||
public enum ScopeKind { Region, Route, Store }
|
||||
|
||||
/// <summary>
|
||||
/// Who a notification goes to. There is deliberately no "everyone" rule — every trigger must say who
|
||||
/// needs to know. Scope coverage follows Region → Route → Store: a region scope covers its routes
|
||||
/// and their stores, a route scope covers its stores.
|
||||
/// </summary>
|
||||
public abstract record RecipientRule
|
||||
{
|
||||
/// <summary>These specific users (creator, driver, approver, route owner …).</summary>
|
||||
public static RecipientRule Users(params Guid[] ids) => new UsersRule(ids);
|
||||
|
||||
/// <summary>Every active user whose role holds the permission code.</summary>
|
||||
public static RecipientRule Permission(string code) => new PermissionRule(code);
|
||||
|
||||
/// <summary>Every active user whose scope covers the region / route / store.</summary>
|
||||
public static RecipientRule Scope(ScopeKind kind, Guid id) => new ScopeRule(kind, id);
|
||||
|
||||
/// <summary>Users who hold the permission AND whose scope covers the region / route / store.</summary>
|
||||
public static RecipientRule PermissionInScope(string code, ScopeKind kind, Guid id) =>
|
||||
new PermissionInScopeRule(code, kind, id);
|
||||
|
||||
/// <summary>The user's supervisor (User.FK_ReportsToUserId).</summary>
|
||||
public static RecipientRule ReportsTo(Guid userId) => new ReportsToRule(userId);
|
||||
|
||||
/// <summary>The owner of the route (<see cref="ScopeKind.Route"/>) or of the store's route (<see cref="ScopeKind.Store"/>).</summary>
|
||||
public static RecipientRule RouteOwner(ScopeKind kind, Guid id) => new RouteOwnerRule(kind, id);
|
||||
}
|
||||
|
||||
public sealed record UsersRule(IReadOnlyCollection<Guid> Ids) : RecipientRule;
|
||||
public sealed record PermissionRule(string Code) : RecipientRule;
|
||||
public sealed record ScopeRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||
public sealed record PermissionInScopeRule(string Code, ScopeKind Kind, Guid Id) : RecipientRule;
|
||||
public sealed record ReportsToRule(Guid UserId) : RecipientRule;
|
||||
public sealed record RouteOwnerRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||
@@ -1,4 +1,4 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
@@ -6,8 +6,14 @@ public record CreatePaymentRequest(
|
||||
decimal Amount,
|
||||
PaymentMethod Method,
|
||||
EntryMode EntryMode = EntryMode.RealTime,
|
||||
// Cash: required. Cheque: optional (used as the deposit account hint if provided).
|
||||
Guid? BankAccountId = null,
|
||||
// Cheque: required.
|
||||
string? ChequeNumber = null,
|
||||
DateTime? ChequeDate = null,
|
||||
// Cheque: optional metadata forwarded to the auto-created ReceivedCheque.
|
||||
string? DrawerBankName = null,
|
||||
string? AccountHolderName = null,
|
||||
Guid? CollectedByUserId = null);
|
||||
|
||||
public record PaymentResponse(
|
||||
@@ -16,6 +22,8 @@ public record PaymentResponse(
|
||||
decimal Amount,
|
||||
PaymentMethod Method,
|
||||
EntryMode EntryMode,
|
||||
Guid? BankAccountId,
|
||||
Guid? ReceivedChequeId,
|
||||
string? ChequeNumber,
|
||||
DateTime? ChequeDate,
|
||||
DateTime CollectedAt,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.StoreCredit;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -8,11 +11,16 @@ namespace Distribution.Features.Payment;
|
||||
public class PaymentService(
|
||||
IPaymentRepository repo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IReceivedChequeRepository receivedChequeRepo,
|
||||
IStoreCreditMovementRepository creditRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode, p.ChequeNumber, p.ChequeDate,
|
||||
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
||||
p.FK_BankAccountId, p.FK_ReceivedChequeId,
|
||||
p.ChequeNumber, p.ChequeDate,
|
||||
p.CollectedAt, p.CollectedBy, p.RecordedBy, p.CreatedAt);
|
||||
|
||||
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
|
||||
@@ -28,13 +36,46 @@ public class PaymentService(
|
||||
{
|
||||
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
|
||||
if (invoice is null || !invoice.IsActive) return null;
|
||||
if (invoice.Status == InvoiceStatus.Finalized)
|
||||
throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
|
||||
|
||||
if (request.Amount <= 0)
|
||||
throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
var existingPaid = (await repo.GetByInvoiceIdAsync(invoiceId)).Sum(p => p.Amount);
|
||||
var remaining = invoice.TotalAmount - existingPaid;
|
||||
if (request.Amount > remaining)
|
||||
throw new InvalidOperationException(
|
||||
$"Amount exceeds the outstanding balance of {remaining:0.##}");
|
||||
|
||||
if (request.Method == PaymentMethod.Cheque &&
|
||||
(string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
|
||||
throw new InvalidOperationException("ChequeNumber and ChequeDate are required when Method is Cheque");
|
||||
|
||||
if (request.Method == PaymentMethod.Cash && request.BankAccountId is null)
|
||||
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||
|
||||
if (request.Method == PaymentMethod.Credit)
|
||||
{
|
||||
var store = invoice.ShopPO?.Store
|
||||
?? throw new InvalidOperationException("Invoice is missing the owning store; cannot record a credit payment");
|
||||
var currentOutstanding = await creditRepo.GetOutstandingForStoreAsync(store.Id);
|
||||
if (currentOutstanding + request.Amount > store.CreditLimit)
|
||||
{
|
||||
var available = store.CreditLimit - currentOutstanding;
|
||||
throw new InvalidOperationException(
|
||||
$"Credit limit exceeded. Available credit: {available:0.##}");
|
||||
}
|
||||
}
|
||||
|
||||
Models.BankAccount? bankAccount = null;
|
||||
if (request.BankAccountId is not null)
|
||||
{
|
||||
bankAccount = await bankAccountRepo.GetByIdAsync(request.BankAccountId.Value);
|
||||
if (bankAccount is null)
|
||||
throw new InvalidOperationException("BankAccountId does not reference a valid account");
|
||||
if (bankAccount.IsActive is false)
|
||||
throw new InvalidOperationException("The selected account is inactive");
|
||||
}
|
||||
|
||||
var recorderId = currentUser.UserId ?? request.CollectedByUserId
|
||||
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
|
||||
var collectedBy = request.CollectedByUserId ?? recorderId;
|
||||
@@ -50,6 +91,7 @@ public class PaymentService(
|
||||
EntryMode = request.EntryMode,
|
||||
ChequeNumber = request.Method == PaymentMethod.Cheque ? request.ChequeNumber : null,
|
||||
ChequeDate = request.Method == PaymentMethod.Cheque ? request.ChequeDate : null,
|
||||
FK_BankAccountId = request.BankAccountId,
|
||||
CollectedAt = DateTime.Now,
|
||||
CollectedBy = collectedBy,
|
||||
RecordedBy = recordedBy
|
||||
@@ -58,11 +100,48 @@ public class PaymentService(
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
// Auto-create a ReceivedCheque for the physical cheque. Status starts at Received;
|
||||
// bookkeeping of the deposit/clear transitions happens through the Received Cheques screen.
|
||||
if (request.Method == PaymentMethod.Cheque)
|
||||
{
|
||||
var cheque = new Models.ReceivedCheque
|
||||
{
|
||||
CompanyId = null,
|
||||
BranchId = null,
|
||||
ReceivedFromType = ReceivedFromType.Customer,
|
||||
ReceivedFromId = null,
|
||||
ReceivedFromName = $"Invoice {invoice.Id}",
|
||||
DrawerBankName = request.DrawerBankName,
|
||||
AccountHolderName = request.AccountHolderName,
|
||||
ChequeNo = request.ChequeNumber!,
|
||||
ChequeDate = request.ChequeDate!.Value,
|
||||
Amount = request.Amount,
|
||||
ReceivedDate = DateTime.Now,
|
||||
ReferenceType = "Invoice",
|
||||
Status = ReceivedChequeStatus.Received
|
||||
};
|
||||
await receivedChequeRepo.AddAsync(cheque);
|
||||
payment.FK_ReceivedChequeId = cheque.Id;
|
||||
}
|
||||
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
|
||||
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
||||
if (request.Method == PaymentMethod.Credit)
|
||||
{
|
||||
var movement = new StoreCreditMovement
|
||||
{
|
||||
FK_StoreId = invoice.ShopPO!.Store!.Id,
|
||||
Direction = StoreCreditMovementDirection.Usage,
|
||||
Amount = request.Amount,
|
||||
FK_InvoiceId = invoice.Id,
|
||||
FK_PaymentId = payment.Id,
|
||||
OccurredAt = DateTime.Now,
|
||||
RecordedBy = recorderId
|
||||
};
|
||||
await creditRepo.AddAsync(movement);
|
||||
}
|
||||
|
||||
var total = existingPaid + request.Amount;
|
||||
invoice.PaymentStatus = total == 0
|
||||
? PaymentStatus.Pending
|
||||
: total < invoice.TotalAmount
|
||||
@@ -70,6 +149,11 @@ public class PaymentService(
|
||||
: PaymentStatus.Paid;
|
||||
|
||||
invoiceRepo.Update(invoice);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Cash/Bank (BankAccount) OR Cheques-in-hand (for Cheque payments)
|
||||
// Credit Accounts Receivable (invoice.Store)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Distribution.Features.PaymentReport;
|
||||
|
||||
public interface IPaymentReportRepository
|
||||
{
|
||||
/// <summary>Active payments collected within [From, To] (whole days).</summary>
|
||||
Task<List<PaymentRow>> GetPaymentsAsync(PaymentReportFilter f);
|
||||
|
||||
/// <summary>Finalized, non-cancelled invoices up to the end of 'To', each with cash/cheque paid as of then.</summary>
|
||||
Task<List<InvoiceBalanceRow>> GetInvoiceBalancesAsync(PaymentReportFilter f);
|
||||
|
||||
/// <summary>Sum of TotalAmount of invoices finalized within [From, To].</summary>
|
||||
Task<decimal> GetInvoicedAsync(PaymentReportFilter f);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.PaymentReport;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/reports/payments")]
|
||||
public class PaymentReportController(PaymentReportService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("analytics")]
|
||||
public async Task<IActionResult> Analytics(
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] Guid? regionId)
|
||||
{
|
||||
var f = PaymentReportService.BuildFilter(from, to, regionId);
|
||||
var error = PaymentReportService.Validate(f);
|
||||
return error is null ? Ok(await service.GetAnalyticsAsync(f)) : BadRequest(new { message = error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.PaymentReport;
|
||||
|
||||
public record PaymentReportFilter(DateTime From, DateTime To, Guid? RegionId);
|
||||
|
||||
public record PaymentSummary(
|
||||
decimal Invoiced,
|
||||
decimal Collected,
|
||||
decimal CreditExtended,
|
||||
decimal Outstanding,
|
||||
decimal Overdue,
|
||||
decimal? CollectionRatePct,
|
||||
int PaymentCount);
|
||||
|
||||
public record MethodBreakdown(string Method, int Count, decimal Amount, decimal SharePct);
|
||||
|
||||
public record PaymentTrendPoint(string Period, decimal Cash, decimal Cheque, decimal Credit);
|
||||
|
||||
public record AgingBucket(string Bucket, int Invoices, decimal Balance);
|
||||
|
||||
public record StoreOutstanding(Guid StoreId, string StoreName, int Invoices, decimal Balance, decimal Overdue);
|
||||
|
||||
public record AccountCollection(Guid? AccountId, string AccountName, int Count, decimal Amount);
|
||||
|
||||
public record ChequePipeline(string Status, int Count, decimal Amount);
|
||||
|
||||
public record PaymentAnalyticsResponse(
|
||||
DateTime From,
|
||||
DateTime To,
|
||||
PaymentSummary Summary,
|
||||
List<MethodBreakdown> ByMethod,
|
||||
List<PaymentTrendPoint> Trend,
|
||||
List<AgingBucket> Aging,
|
||||
List<StoreOutstanding> TopOutstanding,
|
||||
List<AccountCollection> ByAccount,
|
||||
List<ChequePipeline> Cheques);
|
||||
|
||||
// Repository row shapes
|
||||
public record PaymentRow(PaymentMethod Method, decimal Amount, DateTime CollectedAt, Guid? AccountId, string? AccountName, string? ChequeStatus);
|
||||
|
||||
public record InvoiceBalanceRow(Guid StoreId, string StoreName, DateTime FinalizedAt, decimal Total, decimal Paid);
|
||||
@@ -0,0 +1,59 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.PaymentReport;
|
||||
|
||||
public class PaymentReportRepository(AppDBContext db) : IPaymentReportRepository
|
||||
{
|
||||
public async Task<List<PaymentRow>> GetPaymentsAsync(PaymentReportFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q = db.Payments.AsNoTracking()
|
||||
.Where(p => p.IsActive && p.CollectedAt >= start && p.CollectedAt < end
|
||||
&& p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled);
|
||||
if (f.RegionId.HasValue)
|
||||
q = q.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
|
||||
return await q.Select(p => new PaymentRow(
|
||||
p.Method, p.Amount, p.CollectedAt,
|
||||
p.FK_BankAccountId,
|
||||
p.BankAccount == null ? null : p.BankAccount.AccountName,
|
||||
p.ReceivedCheque == null ? null : p.ReceivedCheque.Status.ToString()))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<InvoiceBalanceRow>> GetInvoiceBalancesAsync(PaymentReportFilter f)
|
||||
{
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q = db.Invoices.AsNoTracking()
|
||||
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue)
|
||||
q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
|
||||
// Credit payments do not reduce the receivable (same rule as the dashboard).
|
||||
return await q.Select(i => new InvoiceBalanceRow(
|
||||
i.ShopPO.Store.Id,
|
||||
i.ShopPO.Store.StoreName,
|
||||
i.FinalizedAt!.Value,
|
||||
i.TotalAmount,
|
||||
i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||
.Sum(p => (decimal?)p.Amount) ?? 0m))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<decimal> GetInvoicedAsync(PaymentReportFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q = db.Invoices.AsNoTracking()
|
||||
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||
&& i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue)
|
||||
q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
return await q.SumAsync(i => (decimal?)i.TotalAmount) ?? 0m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.PaymentReport;
|
||||
|
||||
public class PaymentReportService(IPaymentReportRepository repo)
|
||||
{
|
||||
public const int MaxRangeDays = 366;
|
||||
private const int DailyGranularityMaxDays = 92;
|
||||
private const int OverdueDays = 30; // matches the dashboard receivables definition
|
||||
private const int TopStores = 10;
|
||||
|
||||
public static PaymentReportFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId)
|
||||
{
|
||||
var end = (to ?? DateTime.Now).Date;
|
||||
var start = (from ?? end.AddDays(-29)).Date;
|
||||
return new PaymentReportFilter(start, end, regionId);
|
||||
}
|
||||
|
||||
public static string? Validate(PaymentReportFilter f)
|
||||
{
|
||||
if (f.To < f.From) return "'to' must not be before 'from'.";
|
||||
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static decimal Pct(decimal part, decimal whole) => whole > 0 ? Math.Round(part / whole * 100, 2) : 0m;
|
||||
|
||||
public async Task<PaymentAnalyticsResponse> GetAnalyticsAsync(PaymentReportFilter f)
|
||||
{
|
||||
var payments = await repo.GetPaymentsAsync(f);
|
||||
var balances = await repo.GetInvoiceBalancesAsync(f);
|
||||
var invoiced = await repo.GetInvoicedAsync(f);
|
||||
var asOf = f.To.Date.AddDays(1);
|
||||
|
||||
var collected = payments.Where(p => p.Method != PaymentMethod.Credit).Sum(p => p.Amount);
|
||||
var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount);
|
||||
var total = payments.Sum(p => p.Amount);
|
||||
|
||||
var open = balances
|
||||
.Select(b => (b.StoreId, b.StoreName, Age: (asOf - b.FinalizedAt).TotalDays, Balance: Math.Max(0m, b.Total - b.Paid)))
|
||||
.Where(b => b.Balance > 0)
|
||||
.ToList();
|
||||
var outstanding = open.Sum(b => b.Balance);
|
||||
var overdue = open.Where(b => b.Age > OverdueDays).Sum(b => b.Balance);
|
||||
|
||||
var summary = new PaymentSummary(
|
||||
invoiced, collected, credit, outstanding, overdue,
|
||||
invoiced > 0 ? Math.Round(collected / invoiced * 100, 2) : null,
|
||||
payments.Count);
|
||||
|
||||
var byMethod = Enum.GetValues<PaymentMethod>().Select(m =>
|
||||
{
|
||||
var rows = payments.Where(p => p.Method == m).ToList();
|
||||
var amount = rows.Sum(p => p.Amount);
|
||||
return new MethodBreakdown(m.ToString(), rows.Count, amount, Pct(amount, total));
|
||||
}).ToList();
|
||||
|
||||
var daily = (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
|
||||
var trend = payments
|
||||
.GroupBy(p => daily ? p.CollectedAt.ToString("yyyy-MM-dd") : p.CollectedAt.ToString("yyyy-MM"))
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new PaymentTrendPoint(g.Key,
|
||||
g.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount),
|
||||
g.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount),
|
||||
g.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount)))
|
||||
.ToList();
|
||||
|
||||
var buckets = new (string Name, Func<double, bool> In)[]
|
||||
{
|
||||
("0-30 days", a => a <= 30),
|
||||
("31-60 days", a => a > 30 && a <= 60),
|
||||
("61-90 days", a => a > 60 && a <= 90),
|
||||
("90+ days", a => a > 90),
|
||||
};
|
||||
var aging = buckets.Select(b =>
|
||||
{
|
||||
var rows = open.Where(o => b.In(o.Age)).ToList();
|
||||
return new AgingBucket(b.Name, rows.Count, rows.Sum(r => r.Balance));
|
||||
}).ToList();
|
||||
|
||||
var topOutstanding = open
|
||||
.GroupBy(o => (o.StoreId, o.StoreName))
|
||||
.Select(g => new StoreOutstanding(g.Key.StoreId, g.Key.StoreName, g.Count(),
|
||||
g.Sum(x => x.Balance), g.Where(x => x.Age > OverdueDays).Sum(x => x.Balance)))
|
||||
.OrderByDescending(s => s.Balance)
|
||||
.Take(TopStores)
|
||||
.ToList();
|
||||
|
||||
var byAccount = payments
|
||||
.Where(p => p.Method == PaymentMethod.Cash)
|
||||
.GroupBy(p => (p.AccountId, Name: p.AccountName ?? "Unassigned"))
|
||||
.Select(g => new AccountCollection(g.Key.AccountId, g.Key.Name, g.Count(), g.Sum(p => p.Amount)))
|
||||
.OrderByDescending(a => a.Amount)
|
||||
.ToList();
|
||||
|
||||
var cheques = payments
|
||||
.Where(p => p.Method == PaymentMethod.Cheque)
|
||||
.GroupBy(p => p.ChequeStatus ?? "Unknown")
|
||||
.Select(g => new ChequePipeline(g.Key, g.Count(), g.Sum(p => p.Amount)))
|
||||
.OrderByDescending(c => c.Amount)
|
||||
.ToList();
|
||||
|
||||
return new PaymentAnalyticsResponse(f.From, f.To, summary, byMethod, trend, aging, topOutstanding, byAccount, cheques);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.PriceList;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/price-lists")]
|
||||
public class PriceListController(PriceListService service) : ControllerBase
|
||||
public class PriceListController(
|
||||
PriceListService service,
|
||||
IScopeAccessService scopeAccess,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
|
||||
Ok(await service.GetAllAsync(regionId));
|
||||
[Authorize("price-list.view")]
|
||||
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId)
|
||||
{
|
||||
if (regionId.HasValue)
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
|
||||
return StatusCode(403);
|
||||
}
|
||||
return Ok(await service.GetAllAsync(regionId));
|
||||
}
|
||||
|
||||
[HttpGet("resolve")]
|
||||
[Authorize("price-list.view")]
|
||||
public async Task<IActionResult> Resolve([FromQuery] Guid itemId, [FromQuery] Guid storeId, [FromQuery] DateTime saleDate)
|
||||
{
|
||||
var result = await service.ResolveByStoreAsync(itemId, storeId, saleDate);
|
||||
@@ -18,6 +33,7 @@ public class PriceListController(PriceListService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("price-list.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -25,6 +41,7 @@ public class PriceListController(PriceListService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("price-list.manage")]
|
||||
public async Task<IActionResult> Create(CreatePriceListRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -32,6 +49,7 @@ public class PriceListController(PriceListService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("price-list.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdatePriceListRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -46,6 +64,7 @@ public class PriceListController(PriceListService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize("price-list.manage")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -53,6 +72,7 @@ public class PriceListController(PriceListService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items/{sku}")]
|
||||
[Authorize("price-list.manage")]
|
||||
public async Task<IActionResult> UpsertItem(Guid id, string sku, UpsertPriceListItemRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -67,6 +87,7 @@ public class PriceListController(PriceListService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/items/{sku}")]
|
||||
[Authorize("price-list.manage")]
|
||||
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
||||
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||
{
|
||||
var lines =
|
||||
from li in db.ShopPOItems.AsNoTracking()
|
||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||
where inv.Status == InvoiceStatus.Finalized
|
||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
from li in db.InvoiceItems.AsNoTracking()
|
||||
where li.IsActive && !li.IsRemoved
|
||||
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
select li;
|
||||
|
||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||
{
|
||||
var s = f.Search.Trim();
|
||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
||||
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||
});
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
||||
// Distinct orders and stores per item are counted separately: MySQL can't run the correlated
|
||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||
var storePairs = await SoldLines(f)
|
||||
var pairs = await SoldLines(f)
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
||||
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
||||
var orderCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.PoId).Distinct().Count());
|
||||
var storeCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.FK_StoreId).Distinct().Count());
|
||||
|
||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
|
||||
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
||||
SoldLines(f).SumAsync(x => x.Quantity * x.UnitPrice);
|
||||
|
||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||
{
|
||||
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
return await SoldLines(f with { Search = null })
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.GroupBy(l => l.FK_ItemId)
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.Quantity) })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
@@ -9,10 +10,12 @@ namespace Distribution.Features.Po;
|
||||
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("purchase-order.view")]
|
||||
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
|
||||
Ok(await service.GetAllAsync(supplierId, status));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("purchase-order.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -20,6 +23,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("purchase-order.manage")]
|
||||
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
@@ -35,6 +39,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("purchase-order.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -46,6 +51,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/close")]
|
||||
[Authorize("purchase-order.manage")]
|
||||
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -57,6 +63,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
[Authorize("purchase-order.manage")]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -68,6 +75,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
|
||||
[Authorize("purchase-order.manage")]
|
||||
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -79,6 +87,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/match-invoice")]
|
||||
[Authorize("purchase-order.view")]
|
||||
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
|
||||
{
|
||||
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
|
||||
@@ -86,6 +95,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/resolve-variance")]
|
||||
[Authorize("purchase-order.manage")]
|
||||
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.PurchaseReturn;
|
||||
@@ -7,6 +8,7 @@ namespace Distribution.Features.PurchaseReturn;
|
||||
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||
[Authorize("purchase-order.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -14,10 +16,12 @@ public class PurchaseReturnController(PurchaseReturnService service, ICurrentUse
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/returns")]
|
||||
[Authorize("purchase-order.view")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/returns")]
|
||||
[Authorize("purchase-return.manage")]
|
||||
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ReceivedCheque;
|
||||
|
||||
public interface IReceivedChequeRepository
|
||||
{
|
||||
Task<Models.ReceivedCheque?> GetByIdAsync(Guid id);
|
||||
Task<(List<Models.ReceivedCheque> Items, int TotalCount)> ListAsync(
|
||||
int? companyId, int? branchId, ReceivedChequeStatus? status, ReceivedFromType? receivedFromType,
|
||||
int page, int pageSize);
|
||||
Task AddAsync(Models.ReceivedCheque cheque);
|
||||
void Update(Models.ReceivedCheque cheque);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ReceivedCheque;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/received-cheques")]
|
||||
public class ReceivedChequeController(ReceivedChequeService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] int? companyId,
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] ReceivedChequeStatus? status,
|
||||
[FromQuery] ReceivedFromType? receivedFromType,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50)
|
||||
=> Ok(await service.ListAsync(companyId, branchId, status, receivedFromType, page, pageSize));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateReceivedChequeRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(Guid id, UpdateReceivedChequeStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateStatusAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ReceivedCheque;
|
||||
|
||||
public record ReceivedChequeResponse(
|
||||
Guid ReceivedChequeId,
|
||||
int? CompanyId,
|
||||
int? BranchId,
|
||||
ReceivedFromType ReceivedFromType,
|
||||
int? ReceivedFromId,
|
||||
string ReceivedFromName,
|
||||
string? DrawerBankName,
|
||||
string? DrawerBankBranch,
|
||||
string? AccountHolderName,
|
||||
string ChequeNo,
|
||||
DateTime ChequeDate,
|
||||
decimal Amount,
|
||||
DateTime ReceivedDate,
|
||||
string? ReferenceType,
|
||||
int? ReferenceId,
|
||||
string? Notes,
|
||||
string? CreatedBy,
|
||||
ReceivedChequeStatus Status,
|
||||
Guid? DepositBankAccountId,
|
||||
DateTime? DepositDate);
|
||||
|
||||
public record CreateReceivedChequeRequest(
|
||||
int CompanyId,
|
||||
int? BranchId,
|
||||
ReceivedFromType ReceivedFromType,
|
||||
int? ReceivedFromId,
|
||||
string ReceivedFromName,
|
||||
string? DrawerBankName,
|
||||
string? DrawerBankBranch,
|
||||
string? AccountHolderName,
|
||||
string ChequeNo,
|
||||
DateTime ChequeDate,
|
||||
decimal Amount,
|
||||
DateTime ReceivedDate,
|
||||
string? ReferenceType,
|
||||
int? ReferenceId,
|
||||
string? Notes,
|
||||
string? CreatedBy);
|
||||
|
||||
public record UpdateReceivedChequeStatusRequest(
|
||||
ReceivedChequeStatusAction Action,
|
||||
Guid? DepositBankAccountId,
|
||||
DateTime? DepositDate,
|
||||
string? Notes,
|
||||
string? PerformedBy);
|
||||
@@ -0,0 +1,34 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.ReceivedCheque;
|
||||
|
||||
public class ReceivedChequeRepository(AppDBContext context) : IReceivedChequeRepository
|
||||
{
|
||||
public async Task<Models.ReceivedCheque?> GetByIdAsync(Guid id) =>
|
||||
await context.ReceivedCheques.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<(List<Models.ReceivedCheque> Items, int TotalCount)> ListAsync(
|
||||
int? companyId, int? branchId, ReceivedChequeStatus? status, ReceivedFromType? receivedFromType,
|
||||
int page, int pageSize)
|
||||
{
|
||||
var query = context.ReceivedCheques.AsQueryable();
|
||||
if (companyId is not null) query = query.Where(c => c.CompanyId == companyId);
|
||||
if (branchId is not null) query = query.Where(c => c.BranchId == branchId);
|
||||
if (status is not null) query = query.Where(c => c.Status == status);
|
||||
if (receivedFromType is not null) query = query.Where(c => c.ReceivedFromType == receivedFromType);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(c => c.ReceivedDate)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task AddAsync(Models.ReceivedCheque cheque) => await context.ReceivedCheques.AddAsync(cheque);
|
||||
|
||||
public void Update(Models.ReceivedCheque cheque) => context.ReceivedCheques.Update(cheque);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ReceivedCheque;
|
||||
|
||||
public class ReceivedChequeService(
|
||||
IReceivedChequeRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
||||
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
||||
c.DrawerBankName, c.DrawerBankBranch, c.AccountHolderName,
|
||||
c.ChequeNo, c.ChequeDate, c.Amount, c.ReceivedDate,
|
||||
c.ReferenceType, c.ReferenceId, c.Notes, c.CreatedBy,
|
||||
c.Status, c.FK_DepositBankAccountId, c.DepositDate);
|
||||
|
||||
public async Task<PagedResult<ReceivedChequeResponse>> ListAsync(
|
||||
int? companyId, int? branchId, ReceivedChequeStatus? status, ReceivedFromType? receivedFromType,
|
||||
int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 50;
|
||||
|
||||
var (items, total) = await repo.ListAsync(companyId, branchId, status, receivedFromType, page, pageSize);
|
||||
return new PagedResult<ReceivedChequeResponse>(
|
||||
items.Select(ToResponse).ToList(), total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<ReceivedChequeResponse> CreateAsync(CreateReceivedChequeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ReceivedFromName))
|
||||
throw new InvalidOperationException("ReceivedFromName is required");
|
||||
if (string.IsNullOrWhiteSpace(request.ChequeNo))
|
||||
throw new InvalidOperationException("ChequeNo is required");
|
||||
if (request.Amount <= 0)
|
||||
throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
var cheque = new Models.ReceivedCheque
|
||||
{
|
||||
CompanyId = request.CompanyId,
|
||||
BranchId = request.BranchId,
|
||||
ReceivedFromType = request.ReceivedFromType,
|
||||
ReceivedFromId = request.ReceivedFromId,
|
||||
ReceivedFromName = request.ReceivedFromName,
|
||||
DrawerBankName = request.DrawerBankName,
|
||||
DrawerBankBranch = request.DrawerBankBranch,
|
||||
AccountHolderName = request.AccountHolderName,
|
||||
ChequeNo = request.ChequeNo,
|
||||
ChequeDate = request.ChequeDate,
|
||||
Amount = request.Amount,
|
||||
ReceivedDate = request.ReceivedDate,
|
||||
ReferenceType = request.ReferenceType,
|
||||
ReferenceId = request.ReferenceId,
|
||||
Notes = request.Notes,
|
||||
CreatedBy = request.CreatedBy,
|
||||
Status = ReceivedChequeStatus.Received
|
||||
};
|
||||
|
||||
await repo.AddAsync(cheque);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToResponse(cheque);
|
||||
}
|
||||
|
||||
public async Task<ReceivedChequeResponse?> UpdateStatusAsync(Guid id, UpdateReceivedChequeStatusRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var cheque = await repo.GetByIdAsync(id);
|
||||
if (cheque is null) return null;
|
||||
|
||||
switch (request.Action)
|
||||
{
|
||||
case ReceivedChequeStatusAction.Deposit:
|
||||
if (cheque.Status != ReceivedChequeStatus.Received)
|
||||
throw new InvalidOperationException("Only received cheques can be deposited");
|
||||
if (request.DepositBankAccountId is null)
|
||||
throw new InvalidOperationException("DepositBankAccountId is required when depositing");
|
||||
if (request.DepositDate is null)
|
||||
throw new InvalidOperationException("DepositDate is required when depositing");
|
||||
var bankAccount = await bankAccountRepo.GetByIdAsync(request.DepositBankAccountId.Value);
|
||||
if (bankAccount is null || bankAccount.AccountType != BankAccountType.Bank)
|
||||
throw new InvalidOperationException("DepositBankAccountId does not reference a valid bank account");
|
||||
cheque.Status = ReceivedChequeStatus.Deposited;
|
||||
cheque.FK_DepositBankAccountId = request.DepositBankAccountId;
|
||||
cheque.DepositDate = request.DepositDate;
|
||||
break;
|
||||
|
||||
case ReceivedChequeStatusAction.Clear:
|
||||
if (cheque.Status != ReceivedChequeStatus.Deposited)
|
||||
throw new InvalidOperationException("Only deposited cheques can be cleared");
|
||||
cheque.Status = ReceivedChequeStatus.Cleared;
|
||||
break;
|
||||
|
||||
case ReceivedChequeStatusAction.Return:
|
||||
if (cheque.Status != ReceivedChequeStatus.Deposited)
|
||||
throw new InvalidOperationException("Only deposited cheques can be returned");
|
||||
cheque.Status = ReceivedChequeStatus.Returned;
|
||||
break;
|
||||
|
||||
case ReceivedChequeStatusAction.Cancel:
|
||||
if (cheque.Status != ReceivedChequeStatus.Received)
|
||||
throw new InvalidOperationException("Only received cheques can be cancelled");
|
||||
cheque.Status = ReceivedChequeStatus.Cancelled;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
cheque.Notes = request.Notes;
|
||||
|
||||
repo.Update(cheque);
|
||||
await uow.CommitAsync(ct);
|
||||
return ToResponse(cheque);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,39 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Region;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/regions")]
|
||||
public class RegionController(RegionService service) : ControllerBase
|
||||
public class RegionController(
|
||||
RegionService service,
|
||||
IScopeAccessService scopeAccess,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("region.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
// Suggested code for the Add Region form; the user may override it.
|
||||
[HttpGet("next-code")]
|
||||
[Authorize("region.view")]
|
||||
public async Task<IActionResult> GetNextCode() =>
|
||||
Ok(await service.GetNextCodeAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("region.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, id))
|
||||
return StatusCode(403);
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("region.manage")]
|
||||
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,6 +41,7 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("region.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -37,6 +49,7 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize("region.manage")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -44,6 +57,7 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/manager")]
|
||||
[Authorize("region.view")]
|
||||
public async Task<IActionResult> GetManager(Guid id)
|
||||
{
|
||||
var result = await service.GetManagerAsync(id);
|
||||
@@ -51,6 +65,7 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/manager")]
|
||||
[Authorize("region.manage")]
|
||||
public async Task<IActionResult> SetManager(Guid id, SetRegionManagerRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -65,14 +80,15 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/manager")]
|
||||
[Authorize("region.manage")]
|
||||
public async Task<IActionResult> RemoveManager(Guid id, CancellationToken ct)
|
||||
{
|
||||
var removed = await service.RemoveManagerAsync(id, ct);
|
||||
return removed ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
//targets/budgets/agencies/tax-rule.
|
||||
[HttpPut("{id:guid}/manage")]
|
||||
[Authorize("region.manage")]
|
||||
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ManageAsync(id, request, ct);
|
||||
@@ -80,6 +96,7 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/hierarchy")]
|
||||
[Authorize("region.view")]
|
||||
public async Task<IActionResult> GetHierarchy(Guid id)
|
||||
{
|
||||
var result = await service.GetHierarchyAsync(id);
|
||||
@@ -87,18 +104,20 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/dashboard")]
|
||||
[Authorize("region.view")]
|
||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
|
||||
[HttpGet("manage-entries")]
|
||||
[Authorize("region.view")]
|
||||
public async Task<IActionResult> GetManageEntries() =>
|
||||
Ok(await service.GetManageEntriesAsync());
|
||||
|
||||
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
|
||||
[Authorize("region.manage")]
|
||||
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
@@ -7,10 +8,12 @@ namespace Distribution.Features.Returns;
|
||||
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/returns")]
|
||||
[Authorize("return.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("api/returns/{id:guid}")]
|
||||
[Authorize("return.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -18,6 +21,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
|
||||
}
|
||||
|
||||
[HttpPatch("api/returns/{id:guid}/reasons")]
|
||||
[Authorize("return.manage")]
|
||||
public async Task<IActionResult> UpdateReasons(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -29,10 +33,12 @@ public class ReturnController(ReturnService service, ICurrentUserService current
|
||||
}
|
||||
|
||||
[HttpGet("api/shop-pos/{id:guid}/returns")]
|
||||
[Authorize("return.view")]
|
||||
public async Task<IActionResult> GetByShopPO(Guid id) =>
|
||||
Ok(await service.GetByShopPOIdAsync(id));
|
||||
|
||||
[HttpPost("api/shop-pos/{id:guid}/returns")]
|
||||
[Authorize("return.manage")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.DeliveryRide;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Features.DriverProfile;
|
||||
using Distribution.Infra.UoW;
|
||||
@@ -10,9 +12,14 @@ public class RideService(
|
||||
IRideRepository rideRepo,
|
||||
IDriverProfileRepository driverRepo,
|
||||
IDriverComplianceConfigRepository complianceRepo,
|
||||
IDeliveryTaskRepository taskRepo,
|
||||
StockMover mover,
|
||||
INotificationService notifications,
|
||||
ILogger<RideService> logger,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string RidesLink = "/operations/rides";
|
||||
|
||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||
{
|
||||
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||
@@ -72,6 +79,12 @@ public class RideService(
|
||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.RideDriverAssigned,
|
||||
[RecipientRule.Users(driver.FK_UserId)],
|
||||
new NotificationArgs(null, "Ride", rideId, RidesLink),
|
||||
ct);
|
||||
|
||||
var saved = await rideRepo.GetByIdAsync(rideId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
@@ -125,9 +138,46 @@ public class RideService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyRideCancelledAsync(ride, request, userId, ct);
|
||||
|
||||
return ToResponse(ride);
|
||||
}
|
||||
|
||||
// A ride has no route of its own; the people to tell are the owners and managers of the routes
|
||||
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
|
||||
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
|
||||
var routeIds = tasks
|
||||
.Where(t => t.ShopPO?.Store is not null)
|
||||
.Select(t => t.ShopPO.Store.FK_RouteId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var recipients = new List<RecipientRule>();
|
||||
foreach (var routeId in routeIds)
|
||||
{
|
||||
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
|
||||
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
|
||||
}
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.RideCancelled,
|
||||
recipients,
|
||||
new NotificationArgs(
|
||||
new { CancelType = request.TypeCode, Reason = request.Reason },
|
||||
"Ride", ride.Id, RidesLink, userId),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||
{
|
||||
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
||||
|
||||
@@ -5,4 +5,8 @@ public interface IRoleRepository
|
||||
Task<Models.Role?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Role>> GetAllAsync();
|
||||
Task<List<Models.RolePermission>> GetPermissionsAsync(Guid roleId);
|
||||
Task AddAsync(Models.Role role);
|
||||
void Update(Models.Role role);
|
||||
void Remove(Models.Role role);
|
||||
Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds);
|
||||
}
|
||||
|
||||
@@ -23,4 +23,32 @@ public class RoleController(RoleService service) : ControllerBase
|
||||
var result = await service.GetPermissionsAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/permissions")]
|
||||
public async Task<IActionResult> AssignPermissions(Guid id, AssignRolePermissionsRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignPermissionsAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deactivated = await service.DeactivateAsync(id, ct);
|
||||
return deactivated ? NoContent() : NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
namespace Distribution.Features.Role;
|
||||
|
||||
public record RoleResponse(Guid Id, string Name, int Level, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record RolePermissionResponse(Guid PermissionId, string Code, string Description);
|
||||
public record CreateRoleRequest(string Name, int Level);
|
||||
public record UpdateRoleRequest(string Name, int Level);
|
||||
public record AssignRolePermissionsRequest(List<Guid> PermissionIds);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Role;
|
||||
@@ -16,4 +17,29 @@ public class RoleRepository(AppDBContext context) : IRoleRepository
|
||||
.Include(rp => rp.Permission)
|
||||
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.Role role) =>
|
||||
await context.Roles.AddAsync(role);
|
||||
|
||||
public void Update(Models.Role role) =>
|
||||
context.Roles.Update(role);
|
||||
|
||||
public void Remove(Models.Role role)
|
||||
{
|
||||
role.IsActive = false;
|
||||
context.Roles.Update(role);
|
||||
}
|
||||
|
||||
public async Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds)
|
||||
{
|
||||
var existing = await context.RolePermissions
|
||||
.Where(rp => rp.FK_RoleId == roleId)
|
||||
.ToListAsync();
|
||||
context.RolePermissions.RemoveRange(existing);
|
||||
context.RolePermissions.AddRange(permissionIds.Select(pid => new RolePermission
|
||||
{
|
||||
FK_RoleId = roleId,
|
||||
FK_PermissionId = pid
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Role;
|
||||
|
||||
public class RoleService(IRoleRepository repo)
|
||||
public class RoleService(IRoleRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private static RoleResponse ToResponse(Models.Role r) =>
|
||||
new(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt);
|
||||
|
||||
public async Task<List<RoleResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
.Select(r => new RoleResponse(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt))
|
||||
.Select(ToResponse)
|
||||
.ToList();
|
||||
|
||||
public async Task<RoleResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var role = await repo.GetByIdAsync(id);
|
||||
if (role is null || !role.IsActive) return null;
|
||||
return new RoleResponse(role.Id, role.Name, role.Level, role.IsActive, role.CreatedAt);
|
||||
return ToResponse(role);
|
||||
}
|
||||
|
||||
public async Task<List<RolePermissionResponse>?> GetPermissionsAsync(Guid roleId)
|
||||
@@ -23,4 +28,77 @@ public class RoleService(IRoleRepository repo)
|
||||
.Select(rp => new RolePermissionResponse(rp.FK_PermissionId, rp.Permission.Code, rp.Permission.Description))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<RoleResponse> CreateAsync(CreateRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var role = new Models.Role { Name = request.Name, Level = request.Level };
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(role);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(role);
|
||||
}
|
||||
|
||||
public async Task<RoleResponse?> UpdateAsync(Guid id, UpdateRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var role = await repo.GetByIdAsync(id);
|
||||
if (role is null || !role.IsActive) return null;
|
||||
role.Name = request.Name;
|
||||
role.Level = request.Level;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(role);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(role);
|
||||
}
|
||||
|
||||
public async Task<bool> DeactivateAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var role = await repo.GetByIdAsync(id);
|
||||
if (role is null || !role.IsActive) return false;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Remove(role);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<RolePermissionResponse>?> AssignPermissionsAsync(Guid id, AssignRolePermissionsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var role = await repo.GetByIdAsync(id);
|
||||
if (role is null || !role.IsActive) return null;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.ReplacePermissionsAsync(id, request.PermissionIds);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return await GetPermissionsAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Route;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/routes")]
|
||||
public class RouteController(RouteService service) : ControllerBase
|
||||
public class RouteController(
|
||||
RouteService service,
|
||||
IScopeAccessService scopeAccess,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("route.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("route.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, id))
|
||||
return StatusCode(403);
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -25,6 +36,7 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -32,6 +44,7 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/region")]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignToRegionAsync(id, request, ct);
|
||||
@@ -39,6 +52,7 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -46,6 +60,7 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/stores")]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignStoresToRouteAsync(id, request, ct);
|
||||
@@ -53,6 +68,7 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/owner")]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignOwnerAsync(id, request, ct);
|
||||
@@ -60,10 +76,12 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/optimize")]
|
||||
[Authorize("route.manage")]
|
||||
public IActionResult OptimizeSequence(Guid id) =>
|
||||
StatusCode(StatusCodes.Status501NotImplemented);
|
||||
|
||||
[HttpPost("{id:guid}/visits/check-in")]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -75,6 +93,7 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/visits/check-out")]
|
||||
[Authorize("route.manage")]
|
||||
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.RecordCheckOutAsync(id, request, ct);
|
||||
|
||||
@@ -10,7 +10,10 @@ public interface ISchemeRepository
|
||||
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId);
|
||||
void RemoveRedemption(Models.SchemeRedemption entity);
|
||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||
|
||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
@@ -7,10 +8,12 @@ namespace Distribution.Features.Scheme;
|
||||
public class SchemeController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
|
||||
Ok(await service.GetAllAsync(regionId));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -18,6 +21,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> Create(CreateSchemeRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -32,6 +36,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateSchemeRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -46,6 +51,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -53,6 +59,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/free-goods-rule")]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -67,6 +74,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/free-goods-rule")]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> ClearFreeGoodsRule(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ClearFreeGoodsRuleAsync(id, ct);
|
||||
@@ -74,6 +82,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/bundle-components/{itemId:guid}")]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -88,6 +97,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/bundle-components/{itemId:guid}")]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var result = await service.RemoveBundleComponentAsync(id, itemId, ct);
|
||||
@@ -95,13 +105,23 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("eligible")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId)
|
||||
{
|
||||
var result = await service.EvaluateEligibilityAsync(storeId, orderId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("suggest")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> Suggest(SuggestSchemesRequest request)
|
||||
{
|
||||
var result = await service.SuggestAsync(request);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/spend")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> GetSpend(Guid id)
|
||||
{
|
||||
var result = await service.GetSpendAsync(id);
|
||||
@@ -109,6 +129,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/roi")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> GetROI(Guid id)
|
||||
{
|
||||
var result = await service.GetROIAsync(id);
|
||||
@@ -121,6 +142,7 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
public class SchemeOrderController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("{id:guid}/apply-scheme")]
|
||||
[Authorize("scheme.manage")]
|
||||
public async Task<IActionResult> ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -94,7 +94,13 @@ public record SchemeEligibilityResponse(
|
||||
string? Reason,
|
||||
decimal? DiscountAmount,
|
||||
Guid? FreeItemId,
|
||||
decimal? FreeQty);
|
||||
decimal? FreeQty,
|
||||
string? FreeItemName = null);
|
||||
|
||||
/// <summary>A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list.</summary>
|
||||
public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false);
|
||||
|
||||
public record SuggestSchemesRequest(Guid StoreId, List<SuggestLine> Items);
|
||||
|
||||
public record SchemeRedemptionResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -49,9 +49,25 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||
await context.SchemeRedemptions.AddAsync(entity);
|
||||
|
||||
public async Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId)
|
||||
{
|
||||
var list = await context.SchemeRedemptions.Include(r => r.Scheme).Where(r => r.FK_ShopPOId == shopPOId).ToListAsync();
|
||||
// Rows added earlier in this unit of work are not in the database yet; rows removed are still there.
|
||||
foreach (var added in context.SchemeRedemptions.Local.Where(r => r.FK_ShopPOId == shopPOId && !list.Contains(r)))
|
||||
list.Add(added);
|
||||
return list.Where(r => context.Entry(r).State != EntityState.Deleted).ToList();
|
||||
}
|
||||
|
||||
public void RemoveRedemption(Models.SchemeRedemption entity) =>
|
||||
context.SchemeRedemptions.Remove(entity);
|
||||
|
||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||
|
||||
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
|
||||
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
|
||||
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
|
||||
|
||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||
await context.SchemeBundleComponents
|
||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Store;
|
||||
@@ -14,6 +16,8 @@ public class SchemeService(
|
||||
IItemRepository itemRepo,
|
||||
IStoreRepository storeRepo,
|
||||
IStockRepository stockRepo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -261,12 +265,7 @@ public class SchemeService(
|
||||
|
||||
foreach (var scheme in candidates)
|
||||
{
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
||||
continue;
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
|
||||
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||
continue;
|
||||
|
||||
result.Add(EvaluateRule(scheme, items));
|
||||
@@ -275,6 +274,328 @@ public class SchemeService(
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsInScope(Models.Scheme scheme, string? storeSegment, Guid? storeRegionId, DateTime asOf)
|
||||
{
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != storeRegionId) return false;
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, storeSegment, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return asOf >= scheme.DateRangeFrom && asOf.Date <= scheme.DateRangeTo.Date;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggestions for a Shop PO that is still being built (nothing saved yet). Returns eligible schemes,
|
||||
/// plus BuyXGetY/FreeBundle schemes the cart is part-way to, so the UI can nudge ("add 4 more").
|
||||
/// </summary>
|
||||
public async Task<List<SchemeEligibilityResponse>?> SuggestAsync(SuggestSchemesRequest request)
|
||||
{
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
|
||||
var lines = new List<ShopPOItem>();
|
||||
foreach (var l in request.Items.Where(i => !i.IsFree))
|
||||
{
|
||||
var price = l.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(l.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? 0m;
|
||||
lines.Add(new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = price });
|
||||
}
|
||||
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
var inCart = lines.Select(l => l.FK_ItemId).ToHashSet();
|
||||
var result = new List<SchemeEligibilityResponse>();
|
||||
|
||||
foreach (var scheme in (await repo.GetAllAsync()).Where(s => s.IsActive))
|
||||
{
|
||||
if (!IsInScope(scheme, store.Segment, regionId, DateTime.Now)) continue;
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
var relevant = eval.Eligible || scheme.RuleType switch
|
||||
{
|
||||
SchemeRuleType.BuyXGetY => scheme.FK_TriggerItemId.HasValue && inCart.Contains(scheme.FK_TriggerItemId.Value),
|
||||
SchemeRuleType.FreeBundle => scheme.BundleComponents.Any(c => inCart.Contains(c.FK_ItemId)),
|
||||
_ => false
|
||||
};
|
||||
if (!relevant) continue;
|
||||
|
||||
result.Add(eval with { FreeItemName = scheme.FreeItem?.Name });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the free lines of a Shop PO that is about to be saved: each must reference an in-scope scheme the
|
||||
/// paid lines still qualify for, be the scheme's free item, stay within the entitled qty, and be in stock.
|
||||
/// Stamps the scheme-name snapshot and forces UnitPrice to 0. Read-only; throws InvalidOperationException.
|
||||
/// </summary>
|
||||
public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines)
|
||||
{
|
||||
var freeLines = lines.Where(l => l.IsFree).ToList();
|
||||
if (freeLines.Count == 0) return;
|
||||
|
||||
if (freeLines.Any(l => l.FK_SchemeId is null))
|
||||
throw new InvalidOperationException("A free-issue line must reference the scheme that granted it.");
|
||||
if (freeLines.Any(l => l.Quantity <= 0))
|
||||
throw new InvalidOperationException("Free-issue quantity must be greater than zero (remove the line instead).");
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||
?? throw new InvalidOperationException("Store not found.");
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
|
||||
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(group.Key);
|
||||
if (scheme is null || !scheme.IsActive)
|
||||
throw new InvalidOperationException("A free-issue line references a scheme that no longer exists.");
|
||||
if (!IsInScope(scheme, store.Segment, regionId, asOf))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
if (!eval.Eligible || eval.FreeItemId is null || eval.FreeQty is null)
|
||||
throw new InvalidOperationException(eval.Reason ?? $"Order no longer qualifies for scheme '{scheme.Name}'.");
|
||||
if (group.Any(l => l.FK_ItemId != eval.FreeItemId))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' grants a different free item.");
|
||||
|
||||
var qty = group.Sum(l => l.Quantity);
|
||||
if (qty > eval.FreeQty.Value)
|
||||
throw new InvalidOperationException($"Free quantity {qty} exceeds the {eval.FreeQty.Value:0.##} entitled by scheme '{scheme.Name}'.");
|
||||
|
||||
var available = (await stockRepo.GetBalancesByItemAsync(eval.FreeItemId.Value)).Sum(b => b.Quantity);
|
||||
if (available < qty)
|
||||
throw new InvalidOperationException($"Insufficient stock to give {qty} free of scheme '{scheme.Name}'.");
|
||||
|
||||
foreach (var l in group)
|
||||
{
|
||||
l.UnitPrice = 0m;
|
||||
l.SchemeName = scheme.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the percentage-discount schemes (FlatPercent / SlabDiscount) the user accepted for a Shop PO that is
|
||||
/// about to be saved: one at most, in scope, and the paid lines must still qualify. Read-only; throws.
|
||||
/// </summary>
|
||||
public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines, List<Guid>? schemeIds)
|
||||
{
|
||||
if (schemeIds is not { Count: > 0 }) return;
|
||||
if (schemeIds.Distinct().Count() > 1)
|
||||
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||
?? throw new InvalidOperationException("Store not found.");
|
||||
|
||||
var scheme = await repo.GetByIdAsync(schemeIds[0]);
|
||||
if (scheme is null || !scheme.IsActive)
|
||||
throw new InvalidOperationException("The selected discount scheme no longer exists.");
|
||||
if (scheme.RuleType is not (SchemeRuleType.FlatPercent or SchemeRuleType.SlabDiscount))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not a discount scheme.");
|
||||
if (!IsInScope(scheme, store.Segment, store.Route?.FK_RegionId, asOf))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
if (!eval.Eligible)
|
||||
throw new InvalidOperationException(eval.Reason ?? $"Order does not qualify for scheme '{scheme.Name}'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates the percentage discounts redeemed against a Shop PO for the given (invoice) lines and returns
|
||||
/// their total. With persist = true the redemption amounts are updated too (the caller commits), so spend/ROI and
|
||||
/// the invoice stay in step; a discount the lines no longer qualify for drops to 0 and comes back if they requalify.
|
||||
/// </summary>
|
||||
public async Task<decimal> CalculateDiscountAsync(Guid shopPOId, IEnumerable<ShopPOItem> lines, bool persist)
|
||||
{
|
||||
var priced = lines.ToList();
|
||||
decimal total = 0m;
|
||||
foreach (var r in (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue))
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||
var amount = eval is { Eligible: true, DiscountAmount: not null } ? eval.DiscountAmount.Value : 0m;
|
||||
if (persist) r.DiscountAmount = amount;
|
||||
total += amount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// How many free units each scheme currently entitles for these paid lines (0 when no longer eligible).
|
||||
private async Task<Dictionary<Guid, int>> FreeEntitlementsAsync(IEnumerable<Guid> schemeIds, List<ShopPOItem> paidLines)
|
||||
{
|
||||
var result = new Dictionary<Guid, int>();
|
||||
foreach (var id in schemeIds.Distinct())
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, paidLines);
|
||||
result[id] = eval is { Eligible: true, FreeQty: not null } ? (int)Math.Floor(eval.FreeQty.Value) : 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Free-issue entitlement per scheme for unsaved invoice lines (editor preview). Lines carry IsFree / scheme id;
|
||||
/// only the paid ones count toward the scheme rules.
|
||||
/// </summary>
|
||||
public async Task<Dictionary<Guid, int>> GetFreeEntitlementsAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines) =>
|
||||
await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Keeps an invoice's free-issue lines within what the remaining paid lines still earn: when the paid quantity of the
|
||||
/// qualifying item is lowered (or removed), the free quantity is reduced to match (a line that earns nothing is struck
|
||||
/// out) and the PO's redemption is resized. Stages changes only. Returns true if any free line was changed.
|
||||
/// </summary>
|
||||
public async Task<bool> ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List<InvoiceItem> lines)
|
||||
{
|
||||
var freeLines = lines.Where(l => l.IsActive && l.IsFree && l.FK_SchemeId.HasValue).ToList();
|
||||
if (freeLines.Count == 0) return false;
|
||||
|
||||
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||
.ToList();
|
||||
var entitled = await FreeEntitlementsAsync(freeLines.Select(l => l.FK_SchemeId!.Value), paid);
|
||||
var redemptions = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||
var changed = false;
|
||||
|
||||
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||
{
|
||||
var remaining = entitled[group.Key];
|
||||
foreach (var line in group.Where(l => !l.IsRemoved).OrderBy(l => l.CreatedAt))
|
||||
{
|
||||
if (line.Quantity <= remaining) { remaining -= line.Quantity; continue; }
|
||||
|
||||
if (remaining == 0) line.IsRemoved = true;
|
||||
else line.Quantity = remaining;
|
||||
remaining = 0;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var redemption = redemptions.FirstOrDefault(r => r.FK_SchemeId == group.Key && r.FK_FreeItemId.HasValue);
|
||||
if (redemption is not null)
|
||||
redemption.FreeQty = group.Where(l => !l.IsRemoved).Sum(l => l.Quantity);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discount the given schemes would give for these lines, without touching any redemption (editor preview).
|
||||
/// </summary>
|
||||
public async Task<decimal> CalculateDiscountForSchemesAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines)
|
||||
{
|
||||
var priced = lines.ToList();
|
||||
decimal total = 0m;
|
||||
foreach (var id in schemeIds.Distinct())
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null }) total += eval.DiscountAmount.Value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the discount choice made on a Draft invoice (the percentage scheme the user accepted, or none) to the
|
||||
/// PO's redemptions: newly chosen schemes are validated and added, unchosen ones removed. The amount itself is
|
||||
/// then set by CalculateDiscountAsync when the totals are re-derived. Stages changes only. Null = leave as is.
|
||||
/// </summary>
|
||||
public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List<InvoiceItem> lines, List<Guid>? schemeIds)
|
||||
{
|
||||
if (schemeIds is null) return;
|
||||
if (schemeIds.Distinct().Count() > 1)
|
||||
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||
|
||||
var existing = (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue).ToList();
|
||||
foreach (var r in existing.Where(r => !schemeIds.Contains(r.FK_SchemeId)))
|
||||
repo.RemoveRedemption(r);
|
||||
|
||||
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||
.ToList();
|
||||
var added = schemeIds.Where(id => existing.All(r => r.FK_SchemeId != id)).ToList();
|
||||
await ValidateDiscountSchemesAsync(storeId, DateTime.Now, paid, added);
|
||||
|
||||
foreach (var id in added)
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = id, FK_ShopPOId = shopPOId, DiscountAmount = 0m });
|
||||
}
|
||||
|
||||
/// <summary>Discounts already redeemed against a Shop PO, for display and for editing.</summary>
|
||||
public async Task<List<ShopPODiscountResponse>> GetDiscountsForShopPOAsync(Guid shopPOId) =>
|
||||
(await repo.GetRedemptionsByShopPOAsync(shopPOId))
|
||||
.Where(r => r.DiscountAmount.HasValue)
|
||||
.Select(r => new ShopPODiscountResponse(r.FK_SchemeId, r.Scheme?.Name ?? string.Empty, r.DiscountAmount!.Value))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Brings the Shop PO's scheme redemptions in line with its saved lines: free-goods redemptions follow the
|
||||
/// free lines (created, resized or removed), discount redemptions are recomputed or dropped if the order no
|
||||
/// longer qualifies. Stages changes only — the caller owns the commit.
|
||||
/// </summary>
|
||||
public async Task SyncRedemptionsAsync(Guid shopPOId, List<ShopPOItem> lines, List<Guid>? discountSchemeIds = null)
|
||||
{
|
||||
var existing = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||
var freeBySchemes = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue)
|
||||
.GroupBy(l => l.FK_SchemeId!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var r in existing)
|
||||
{
|
||||
if (r.FK_FreeItemId.HasValue)
|
||||
{
|
||||
if (!freeBySchemes.ContainsKey(r.FK_SchemeId)) repo.RemoveRedemption(r);
|
||||
}
|
||||
else if (r.DiscountAmount.HasValue)
|
||||
{
|
||||
// A null list means "leave the accepted discounts alone" (just recompute them).
|
||||
if (discountSchemeIds is not null && !discountSchemeIds.Contains(r.FK_SchemeId))
|
||||
{
|
||||
repo.RemoveRedemption(r);
|
||||
continue;
|
||||
}
|
||||
|
||||
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null }) r.DiscountAmount = eval.DiscountAmount;
|
||||
else repo.RemoveRedemption(r);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var schemeId in discountSchemeIds ?? [])
|
||||
{
|
||||
if (existing.Any(r => r.FK_SchemeId == schemeId && r.DiscountAmount.HasValue)) continue;
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null })
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_ShopPOId = shopPOId,
|
||||
DiscountAmount = eval.DiscountAmount
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var (schemeId, group) in freeBySchemes)
|
||||
{
|
||||
var freeItem = await itemRepo.GetByIdAsync(group[0].FK_ItemId)
|
||||
?? throw new InvalidOperationException("Free item not found.");
|
||||
var qty = group.Sum(l => l.Quantity);
|
||||
var redemption = existing.FirstOrDefault(r => r.FK_SchemeId == schemeId && r.FK_FreeItemId.HasValue);
|
||||
|
||||
if (redemption is null)
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_ShopPOId = shopPOId,
|
||||
FK_FreeItemId = freeItem.Id,
|
||||
FreeQty = qty,
|
||||
FreeItemCostValue = freeItem.CostPrice ?? 0m
|
||||
});
|
||||
else
|
||||
{
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
redemption.FreeQty = qty;
|
||||
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
||||
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
||||
@@ -299,14 +620,18 @@ public class SchemeService(
|
||||
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||
|
||||
var today = DateTime.Now;
|
||||
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
|
||||
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
|
||||
return (false, "Scheme is not currently active (outside its date range).");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> allItems)
|
||||
{
|
||||
// Free-issue lines (added by a previous redemption) must not count toward a trigger/bundle
|
||||
// quantity, otherwise the free units inflate the multiplier.
|
||||
var items = allItems.Where(i => !i.IsFree && i.UnitPrice > 0).ToList();
|
||||
|
||||
switch (scheme.RuleType)
|
||||
{
|
||||
case SchemeRuleType.BuyXGetY:
|
||||
@@ -383,6 +708,9 @@ public class SchemeService(
|
||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||
|
||||
if (await repo.HasLockedInvoiceAsync(shopPOId))
|
||||
throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed.");
|
||||
|
||||
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||
var eligibility = EvaluateRule(scheme, items);
|
||||
if (!eligibility.Eligible)
|
||||
@@ -411,7 +739,10 @@ public class SchemeService(
|
||||
FK_ShopPOId = shopPOId,
|
||||
FK_ItemId = freeItem.Id,
|
||||
Quantity = (int)eligibility.FreeQty.Value,
|
||||
UnitPrice = 0m
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = scheme.Id,
|
||||
SchemeName = scheme.Name
|
||||
});
|
||||
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
@@ -422,7 +753,27 @@ public class SchemeService(
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
||||
if (newItems.Count > 0)
|
||||
{
|
||||
await shopPORepo.AddItemsAsync(newItems);
|
||||
|
||||
// A still-draft invoice was copied from the PO before this line existed, so mirror it there.
|
||||
if (shopPO.InvoiceId is Guid invoiceId &&
|
||||
await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft })
|
||||
{
|
||||
await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem
|
||||
{
|
||||
FK_InvoiceId = invoiceId,
|
||||
FK_ShopPOItemId = n.Id,
|
||||
FK_ItemId = n.FK_ItemId,
|
||||
Quantity = n.Quantity,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = n.FK_SchemeId,
|
||||
SchemeName = n.SchemeName
|
||||
}));
|
||||
}
|
||||
}
|
||||
await repo.AddRedemptionAsync(redemption);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -484,6 +835,8 @@ public class SchemeService(
|
||||
|
||||
if (freeItemId is null || freeQty is null or <= 0)
|
||||
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
||||
if (freeQty.Value != decimal.Truncate(freeQty.Value))
|
||||
throw new InvalidOperationException("FreeQty must be a whole number (order lines hold whole units).");
|
||||
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
||||
throw new InvalidOperationException("Free item not found.");
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("confirmed-without-invoice")]
|
||||
public async Task<IActionResult> GetConfirmedWithoutInvoice(
|
||||
[FromQuery] Guid? agentId,
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? shopId)
|
||||
{
|
||||
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
namespace Distribution.Features.ShopPO;
|
||||
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
||||
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
||||
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive,
|
||||
bool IsFree = false, Guid? SchemeId = null, string? SchemeName = null);
|
||||
|
||||
public record ShopPODiscountResponse(Guid SchemeId, string SchemeName, decimal Amount);
|
||||
|
||||
public record ShopPOResponse(
|
||||
Guid Id,
|
||||
@@ -34,4 +39,5 @@ public record ShopPOResponse(
|
||||
DateTime? CancelledAt = null,
|
||||
string? CancelReason = null,
|
||||
string? CancelledByName = null,
|
||||
string? CancelledByRole = null);
|
||||
string? CancelledByRole = null,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -10,16 +12,47 @@ public class ShopPOService(
|
||||
IShopPORepository repo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
SchemeService schemeService,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
||||
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
|
||||
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
|
||||
{
|
||||
if (line.IsFree)
|
||||
return new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = line.SchemeId
|
||||
};
|
||||
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
|
||||
}
|
||||
|
||||
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
|
||||
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
|
||||
notifications.NotifyAsync(
|
||||
type,
|
||||
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
|
||||
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
|
||||
ct);
|
||||
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
|
||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||
p.Items.Where(i => i.IsActive).Select(i =>
|
||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
|
||||
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
|
||||
p.IsActive, p.CreatedAt,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
||||
@@ -27,7 +60,8 @@ public class ShopPOService(
|
||||
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
||||
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
|
||||
discounts ?? []);
|
||||
|
||||
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||
{
|
||||
@@ -52,7 +86,14 @@ public class ShopPOService(
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null) return null;
|
||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
||||
return ToResponse(po, users);
|
||||
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
|
||||
}
|
||||
|
||||
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||
{
|
||||
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
||||
return items.Select(p => ToResponse(p, users)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
||||
@@ -65,20 +106,14 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
items.Add(new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
var item = await BuildLineAsync(line, request.StoreId);
|
||||
items.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||
|
||||
var po = new Models.ShopPO
|
||||
{
|
||||
FK_StoreId = request.StoreId,
|
||||
@@ -93,6 +128,7 @@ public class ShopPOService(
|
||||
await repo.AddAsync(po);
|
||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||
await repo.AddItemsAsync(items);
|
||||
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
@@ -120,27 +156,22 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
newItems.Add(new ShopPOItem
|
||||
{
|
||||
FK_ShopPOId = id,
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||
item.FK_ShopPOId = id;
|
||||
newItems.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||
|
||||
po.TotalAmount = total;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.ReplaceItemsAsync(id, newItems);
|
||||
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -168,6 +199,8 @@ public class ShopPOService(
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
|
||||
@@ -193,17 +226,25 @@ public class ShopPOService(
|
||||
CreatedBy = userId,
|
||||
TotalAmount = po.TotalAmount
|
||||
};
|
||||
// Start the invoice at the discounted total so it matches what print/finalize will recompute.
|
||||
invoice.DiscountAmount = Math.Min(await invoiceRepo.GetSchemeDiscountAsync(po.Id), po.TotalAmount);
|
||||
invoice.TotalAmount = po.TotalAmount - invoice.DiscountAmount;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await invoiceRepo.AddAsync(invoice);
|
||||
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
|
||||
await invoiceRepo.AddItemsAsync(lines);
|
||||
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
|
||||
po.InvoiceId = invoice.Id;
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
|
||||
|
||||
var saved = await repo.GetByIdAsync(id);
|
||||
return await ToResponseWithUserAsync(saved!);
|
||||
}
|
||||
@@ -231,6 +272,8 @@ public class ShopPOService(
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Stock;
|
||||
@@ -8,9 +9,10 @@ namespace Distribution.Features.Stock;
|
||||
[Route("api/stock")]
|
||||
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
// ── Query ──────────────────────────────────────────────────────────────
|
||||
// ── Query ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("balances")]
|
||||
[Authorize("stock.view")]
|
||||
public async Task<IActionResult> GetBalances(
|
||||
[FromQuery] Guid? locationId,
|
||||
[FromQuery] LocationType? locationType,
|
||||
@@ -23,6 +25,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpGet("low-stock")]
|
||||
[Authorize("stock.view")]
|
||||
public async Task<IActionResult> GetLowStock([FromQuery] Guid? locationId)
|
||||
{
|
||||
var result = await service.GetLowStockAsync(locationId);
|
||||
@@ -30,6 +33,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpGet("balances/{itemId}/total")]
|
||||
[Authorize("stock.view")]
|
||||
public async Task<IActionResult> GetItemTotal(Guid itemId)
|
||||
{
|
||||
var result = await service.GetItemTotalAsync(itemId);
|
||||
@@ -39,6 +43,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
|
||||
/// <summary>Next free number for auto-generated serials (e.g. after ...-0005 the answer is 6).</summary>
|
||||
[HttpGet("serials/next-sequence")]
|
||||
[Authorize("stock.view")]
|
||||
public async Task<IActionResult> GetNextSerialSequence([FromQuery] Guid itemId, [FromQuery] string prefix = "")
|
||||
{
|
||||
var next = await service.GetNextSerialSequenceAsync(itemId, prefix);
|
||||
@@ -46,6 +51,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpGet("movements")]
|
||||
//[Authorize("stock.view")]
|
||||
public async Task<IActionResult> GetMovements(
|
||||
[FromQuery] Guid? locationId,
|
||||
[FromQuery] Guid? itemId,
|
||||
@@ -77,9 +83,10 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
// ── Write ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("transfer")]
|
||||
[Authorize("stock.manage")]
|
||||
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is null) return Unauthorized();
|
||||
@@ -92,6 +99,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpPost("damage")]
|
||||
[Authorize("stock.manage")]
|
||||
public async Task<IActionResult> RecordDamage(DamageRequest request, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is null) return Unauthorized();
|
||||
@@ -104,6 +112,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpPost("customer-return")]
|
||||
[Authorize("stock.manage")]
|
||||
public async Task<IActionResult> RecordCustomerReturn(CustomerReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is null) return Unauthorized();
|
||||
@@ -116,6 +125,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpPost("adjustment")]
|
||||
[Authorize("stock.manage")]
|
||||
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
@@ -131,6 +141,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
}
|
||||
|
||||
[HttpPatch("movements/{id}/reason")]
|
||||
[Authorize("stock.manage")]
|
||||
public async Task<IActionResult> UpdateMovementReason(Guid id, UpdateMovementReasonRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stores")]
|
||||
public class StoreController(StoreService service) : ControllerBase
|
||||
public class StoreController(
|
||||
StoreService service,
|
||||
IScopeAccessService scopeAccess,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("store.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] string? search,
|
||||
@@ -16,13 +22,18 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
Ok(await service.GetAllAsync(routeId, search, page, pageSize));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("store.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
|
||||
// return StatusCode(403);
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("store.manage")]
|
||||
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,6 +41,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("store.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -37,6 +49,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize("store.manage")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -44,6 +57,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/credit-limit")]
|
||||
[Authorize("store.manage")]
|
||||
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateCreditLimitAsync(id, request, ct);
|
||||
@@ -51,6 +65,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/outstanding-balance")]
|
||||
[Authorize("store.manage")]
|
||||
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
|
||||
@@ -58,6 +73,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/outstanding-balance")]
|
||||
[Authorize("store.view")]
|
||||
public async Task<IActionResult> GetOutstandingBalance(Guid id)
|
||||
{
|
||||
var result = await service.GetOutstandingBalanceAsync(id);
|
||||
@@ -65,6 +81,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/segment")]
|
||||
[Authorize("store.manage")]
|
||||
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ClassifySegmentAsync(id, request, ct);
|
||||
@@ -72,6 +89,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/visit-history")]
|
||||
[Authorize("store.view")]
|
||||
public async Task<IActionResult> GetVisitHistory(Guid id)
|
||||
{
|
||||
var result = await service.GetVisitHistoryAsync(id);
|
||||
@@ -79,6 +97,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
|
||||
[Authorize("store.view")]
|
||||
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
|
||||
{
|
||||
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
|
||||
@@ -86,6 +105,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/dashboard")]
|
||||
[Authorize("store.view")]
|
||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
@@ -100,6 +120,7 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[Authorize("store.manage")]
|
||||
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.SetActiveStatusAsync(id, request.IsActive, ct);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.StoreCredit;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo)
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
private const int CreditApproverRoleLevel = 20;
|
||||
|
||||
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||
private async Task<bool> HasGlobalScopeAsync() => await currentUser.GetRoleLevelAsync() >= GlobalScopeRoleLevel;
|
||||
|
||||
private async Task<bool> HasStoreScopeAsync(Guid storeId)
|
||||
{
|
||||
@@ -122,7 +124,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
|
||||
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
|
||||
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
@@ -143,10 +145,11 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
|
||||
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
|
||||
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
|
||||
store.OutstandingBalance = request.NewBalance;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -159,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Only on the crossing, so repeated updates while over the limit don't re-alert.
|
||||
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.CreditLimitBreached,
|
||||
[
|
||||
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
|
||||
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
|
||||
],
|
||||
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
|
||||
ct);
|
||||
|
||||
return ToResponse(store);
|
||||
}
|
||||
|
||||
@@ -167,7 +182,8 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
|
||||
var outstanding = await creditRepo.GetOutstandingForStoreAsync(id);
|
||||
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, outstanding, store.CreditLimit - outstanding);
|
||||
}
|
||||
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.StoreCredit;
|
||||
|
||||
public interface IStoreCreditMovementRepository
|
||||
{
|
||||
Task<decimal> GetOutstandingForStoreAsync(Guid storeId);
|
||||
Task<List<StoreCreditMovement>> GetByStoreAsync(Guid storeId, int page, int pageSize);
|
||||
Task<int> GetCountByStoreAsync(Guid storeId);
|
||||
Task AddAsync(StoreCreditMovement movement);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.StoreCredit;
|
||||
|
||||
public class StoreCreditMovementRepository(AppDBContext context) : IStoreCreditMovementRepository
|
||||
{
|
||||
public async Task<decimal> GetOutstandingForStoreAsync(Guid storeId)
|
||||
{
|
||||
var usage = await context.StoreCreditMovements
|
||||
.Where(m => m.IsActive && m.FK_StoreId == storeId && m.Direction == StoreCreditMovementDirection.Usage)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||
|
||||
var settlements = await context.StoreCreditMovements
|
||||
.Where(m => m.IsActive && m.FK_StoreId == storeId && m.Direction == StoreCreditMovementDirection.Settlement)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||
|
||||
return usage - settlements;
|
||||
}
|
||||
|
||||
public async Task<List<StoreCreditMovement>> GetByStoreAsync(Guid storeId, int page, int pageSize) =>
|
||||
await context.StoreCreditMovements
|
||||
.Where(m => m.IsActive && m.FK_StoreId == storeId)
|
||||
.OrderByDescending(m => m.OccurredAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountByStoreAsync(Guid storeId) =>
|
||||
await context.StoreCreditMovements
|
||||
.CountAsync(m => m.IsActive && m.FK_StoreId == storeId);
|
||||
|
||||
public async Task AddAsync(StoreCreditMovement movement) =>
|
||||
await context.StoreCreditMovements.AddAsync(movement);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
@@ -7,10 +8,12 @@ namespace Distribution.Features.SupplierClaims;
|
||||
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/supplier-claims")]
|
||||
[Authorize("supplier-claim.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("api/supplier-claims/{id:guid}")]
|
||||
[Authorize("supplier-claim.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -18,10 +21,12 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/claims")]
|
||||
[Authorize("supplier-claim.view")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/claims")]
|
||||
[Authorize("supplier-claim.manage")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
@@ -37,6 +42,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
|
||||
}
|
||||
|
||||
[HttpPut("api/supplier-claims/{id:guid}")]
|
||||
[Authorize("supplier-claim.manage")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -48,6 +54,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/approve")]
|
||||
[Authorize("supplier-claim.manage")]
|
||||
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.ApprovedByUserId;
|
||||
@@ -62,6 +69,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/reject")]
|
||||
[Authorize("supplier-claim.manage")]
|
||||
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.RejectedByUserId;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -9,6 +10,7 @@ public class SupplierClaimService(
|
||||
ISupplierClaimRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IGrnRepository grnRepo,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||
@@ -149,9 +151,19 @@ public class SupplierClaimService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
|
||||
// Tells whoever raised the claim how it was decided (never the person who decided it).
|
||||
private Task NotifyDecisionAsync(string type, SupplierClaim claim, Guid decidedBy, CancellationToken ct) =>
|
||||
notifications.NotifyAsync(
|
||||
type,
|
||||
[RecipientRule.Users(claim.RaisedByUserId)],
|
||||
new NotificationArgs(null, "SupplierClaim", claim.Id, "/returns-claims/supplier-claims", decidedBy),
|
||||
ct);
|
||||
|
||||
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id)
|
||||
@@ -172,6 +184,8 @@ public class SupplierClaimService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.SupplierPayment;
|
||||
|
||||
public interface ISupplierPaymentRepository
|
||||
{
|
||||
Task<Models.SupplierPayment?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.SupplierPayment>> ListBySupplierAsync(Guid supplierId, int page, int pageSize);
|
||||
Task<int> CountBySupplierAsync(Guid supplierId);
|
||||
Task AddAsync(Models.SupplierPayment payment);
|
||||
|
||||
/// <summary>Sum of currently-allocated amounts to a GRN, across all active SupplierPayments.</summary>
|
||||
Task<decimal> GetAllocatedTotalForGrnAsync(Guid goodsReceiptId);
|
||||
|
||||
/// <summary>Load a GRN with its items so the total can be computed.</summary>
|
||||
Task<Models.GoodsReceipt?> GetGrnWithItemsAsync(Guid goodsReceiptId);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.SupplierPayment;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/suppliers/{supplierId:guid}/payments")]
|
||||
public class SupplierPaymentController(SupplierPaymentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(Guid supplierId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.ListBySupplierAsync(supplierId, page, pageSize);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(Guid supplierId, CreateSupplierPaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(supplierId, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.SupplierPayment;
|
||||
|
||||
public record SupplierPaymentAllocationRequest(
|
||||
Guid GoodsReceiptId,
|
||||
decimal AllocatedAmount);
|
||||
|
||||
public record CreateSupplierPaymentRequest(
|
||||
decimal Amount,
|
||||
PaymentMethod Method,
|
||||
// One or more GRNs this payment covers. Sum of AllocatedAmount must equal Amount.
|
||||
List<SupplierPaymentAllocationRequest> Allocations,
|
||||
// Cash: required.
|
||||
Guid? BankAccountId = null,
|
||||
// Cheque: required — an Unused page from an Active cheque book.
|
||||
Guid? ChequePageId = null,
|
||||
string? Notes = null,
|
||||
Guid? PaidByUserId = null);
|
||||
|
||||
public record SupplierPaymentAllocationResponse(
|
||||
Guid GoodsReceiptId,
|
||||
decimal AllocatedAmount);
|
||||
|
||||
public record SupplierPaymentResponse(
|
||||
Guid Id,
|
||||
Guid SupplierId,
|
||||
decimal Amount,
|
||||
PaymentMethod Method,
|
||||
Guid? BankAccountId,
|
||||
Guid? ChequePageId,
|
||||
string? ChequeNo,
|
||||
DateTime PaidAt,
|
||||
Guid? PaidByUserId,
|
||||
string? Notes,
|
||||
List<SupplierPaymentAllocationResponse> Allocations,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record PagedSupplierPaymentsResponse(
|
||||
List<SupplierPaymentResponse> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int Total);
|
||||
@@ -0,0 +1,43 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.SupplierPayment;
|
||||
|
||||
public class SupplierPaymentRepository(AppDBContext context) : ISupplierPaymentRepository
|
||||
{
|
||||
public async Task<Models.SupplierPayment?> GetByIdAsync(Guid id) =>
|
||||
await context.SupplierPayments
|
||||
.Include(p => p.ChequePage)
|
||||
.Include(p => p.Allocations)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
public async Task<List<Models.SupplierPayment>> ListBySupplierAsync(Guid supplierId, int page, int pageSize) =>
|
||||
await context.SupplierPayments
|
||||
.Include(p => p.ChequePage)
|
||||
.Include(p => p.Allocations)
|
||||
.Where(p => p.FK_SupplierId == supplierId && p.IsActive)
|
||||
.OrderByDescending(p => p.PaidAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> CountBySupplierAsync(Guid supplierId) =>
|
||||
await context.SupplierPayments
|
||||
.CountAsync(p => p.FK_SupplierId == supplierId && p.IsActive);
|
||||
|
||||
public async Task AddAsync(Models.SupplierPayment payment) =>
|
||||
await context.SupplierPayments.AddAsync(payment);
|
||||
|
||||
public async Task<decimal> GetAllocatedTotalForGrnAsync(Guid goodsReceiptId) =>
|
||||
await context.SupplierPaymentGRNs
|
||||
.Where(a => a.FK_GoodsReceiptId == goodsReceiptId
|
||||
&& a.IsActive
|
||||
&& a.SupplierPayment!.IsActive)
|
||||
.SumAsync(a => (decimal?)a.AllocatedAmount) ?? 0m;
|
||||
|
||||
public async Task<Models.GoodsReceipt?> GetGrnWithItemsAsync(Guid goodsReceiptId) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == goodsReceiptId && g.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Supplier;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.SupplierPayment;
|
||||
|
||||
public class SupplierPaymentService(
|
||||
AppDBContext context,
|
||||
ISupplierPaymentRepository repo,
|
||||
ISupplierRepository supplierRepo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IChequeBookRepository chequeBookRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
||||
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
||||
p.FK_BankAccountId, p.FK_ChequePageId, p.ChequePage?.ChequeNo,
|
||||
p.PaidAt, p.PaidByUserId, p.Notes,
|
||||
p.Allocations
|
||||
.Where(a => a.IsActive)
|
||||
.Select(a => new SupplierPaymentAllocationResponse(a.FK_GoodsReceiptId, a.AllocatedAmount))
|
||||
.ToList(),
|
||||
p.CreatedAt);
|
||||
|
||||
/// <summary>GRN total = sum per line of (gross - discount + NBT + VAT). Rejected qty never costs.</summary>
|
||||
private static decimal GrnTotal(Models.GoodsReceipt grn) =>
|
||||
grn.Items.Where(i => i.IsActive).Sum(i =>
|
||||
GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent).Total);
|
||||
|
||||
public async Task<PagedSupplierPaymentsResponse?> ListBySupplierAsync(Guid supplierId, int page, int pageSize)
|
||||
{
|
||||
var supplier = await supplierRepo.GetByIdAsync(supplierId);
|
||||
if (supplier is null) return null;
|
||||
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
if (pageSize > 200) pageSize = 200;
|
||||
|
||||
var payments = await repo.ListBySupplierAsync(supplierId, page, pageSize);
|
||||
var total = await repo.CountBySupplierAsync(supplierId);
|
||||
return new PagedSupplierPaymentsResponse(payments.Select(ToResponse).ToList(), page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<SupplierPaymentResponse?> CreateAsync(Guid supplierId, CreateSupplierPaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var supplier = await supplierRepo.GetByIdAsync(supplierId);
|
||||
if (supplier is null) return null;
|
||||
|
||||
if (request.Amount <= 0)
|
||||
throw new InvalidOperationException("Amount must be greater than zero");
|
||||
if (request.Method == PaymentMethod.Credit)
|
||||
throw new InvalidOperationException("Credit is not a valid outgoing payment method");
|
||||
|
||||
if (request.Allocations is null || request.Allocations.Count == 0)
|
||||
throw new InvalidOperationException("At least one GRN allocation is required");
|
||||
|
||||
var duplicateGrn = request.Allocations.GroupBy(a => a.GoodsReceiptId).FirstOrDefault(g => g.Count() > 1);
|
||||
if (duplicateGrn is not null)
|
||||
throw new InvalidOperationException($"GRN {duplicateGrn.Key} is allocated more than once");
|
||||
|
||||
if (request.Allocations.Any(a => a.AllocatedAmount <= 0))
|
||||
throw new InvalidOperationException("Each allocation amount must be greater than zero");
|
||||
|
||||
var allocationSum = request.Allocations.Sum(a => a.AllocatedAmount);
|
||||
if (allocationSum != request.Amount)
|
||||
throw new InvalidOperationException($"Allocations sum ({allocationSum}) must equal Amount ({request.Amount})");
|
||||
|
||||
// Validate each GRN exists, belongs to this supplier, and the new allocation does not push cumulative paid over the GRN total.
|
||||
foreach (var alloc in request.Allocations)
|
||||
{
|
||||
var grn = await repo.GetGrnWithItemsAsync(alloc.GoodsReceiptId)
|
||||
?? throw new InvalidOperationException($"GRN {alloc.GoodsReceiptId} not found");
|
||||
|
||||
var grnSupplierId = grn.FK_SupplierId
|
||||
?? grn.PurchaseOrder?.FK_SupplierId;
|
||||
if (grnSupplierId is null)
|
||||
{
|
||||
// PO-based GRN without the PO loaded — re-query to confirm supplier match.
|
||||
var po = await context.PurchaseOrders.FindAsync([grn.FK_PurchaseOrderId], ct);
|
||||
grnSupplierId = po?.FK_SupplierId;
|
||||
}
|
||||
if (grnSupplierId != supplierId)
|
||||
throw new InvalidOperationException($"GRN {alloc.GoodsReceiptId} does not belong to the specified supplier");
|
||||
|
||||
var grnTotal = GrnTotal(grn);
|
||||
var alreadyAllocated = await repo.GetAllocatedTotalForGrnAsync(grn.Id);
|
||||
if (alreadyAllocated + alloc.AllocatedAmount > grnTotal)
|
||||
throw new InvalidOperationException(
|
||||
$"Allocation to GRN {grn.Id} ({alloc.AllocatedAmount}) exceeds outstanding balance " +
|
||||
$"(GRN total {grnTotal}, already paid {alreadyAllocated})");
|
||||
}
|
||||
|
||||
var payerId = currentUser.UserId ?? request.PaidByUserId;
|
||||
|
||||
var payment = new Models.SupplierPayment
|
||||
{
|
||||
FK_SupplierId = supplierId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
PaidAt = DateTime.Now,
|
||||
PaidByUserId = payerId,
|
||||
Notes = request.Notes,
|
||||
Allocations = request.Allocations.Select(a => new Models.SupplierPaymentGRN
|
||||
{
|
||||
FK_GoodsReceiptId = a.GoodsReceiptId,
|
||||
AllocatedAmount = a.AllocatedAmount
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (request.Method == PaymentMethod.Cash)
|
||||
{
|
||||
if (request.BankAccountId is null)
|
||||
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||
|
||||
var account = await bankAccountRepo.GetByIdAsync(request.BankAccountId.Value);
|
||||
if (account is null)
|
||||
throw new InvalidOperationException("BankAccountId does not reference a valid account");
|
||||
if (account.IsActive is false)
|
||||
throw new InvalidOperationException("The selected account is inactive");
|
||||
|
||||
payment.FK_BankAccountId = account.Id;
|
||||
}
|
||||
else // Cheque
|
||||
{
|
||||
if (request.ChequePageId is null)
|
||||
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
|
||||
|
||||
var page = await context.ChequePages
|
||||
.Include(p => p.ChequeBook)
|
||||
.FirstOrDefaultAsync(p => p.Id == request.ChequePageId.Value, ct);
|
||||
|
||||
if (page is null)
|
||||
throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Unused)
|
||||
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
|
||||
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
|
||||
throw new InvalidOperationException("The cheque book is not active");
|
||||
|
||||
page.IssueStatus = ChequePageIssueStatus.Issued;
|
||||
page.PayeeType = PayeeType.Supplier;
|
||||
page.PayeeName = supplier.Name;
|
||||
page.IssueDate = DateTime.Now;
|
||||
page.Amount = request.Amount;
|
||||
page.CurrencyCode = "LKR";
|
||||
page.PrintedAt = DateTime.Now;
|
||||
|
||||
chequeBookRepo.UpdatePage(page);
|
||||
payment.FK_ChequePageId = page.Id;
|
||||
payment.ChequePage = page;
|
||||
}
|
||||
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Accounts Payable (supplier)
|
||||
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(payment);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.SysConfig;
|
||||
@@ -9,11 +10,12 @@ namespace Distribution.Features.SysConfig;
|
||||
public class SysConfigController(SysConfigService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("sys-config.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
// Typed shortcut — must be declared before {key} to take precedence
|
||||
[HttpGet("invoice-creation-mode")]
|
||||
[Authorize("sys-config.view")]
|
||||
public async Task<IActionResult> GetInvoiceCreationMode()
|
||||
{
|
||||
var mode = await service.GetInvoiceCreationModeAsync();
|
||||
@@ -21,6 +23,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
|
||||
}
|
||||
|
||||
[HttpPut("invoice-creation-mode")]
|
||||
[Authorize("sys-config.manage")]
|
||||
public async Task<IActionResult> SetInvoiceCreationMode(SetInvoiceCreationModeRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -33,6 +36,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
|
||||
}
|
||||
|
||||
[HttpGet("{key}")]
|
||||
[Authorize("sys-config.view")]
|
||||
public async Task<IActionResult> GetByKey(string key)
|
||||
{
|
||||
var result = await service.GetByKeyAsync(key);
|
||||
@@ -40,6 +44,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
|
||||
}
|
||||
|
||||
[HttpPut("{key}")]
|
||||
[Authorize("sys-config.manage")]
|
||||
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.UserId;
|
||||
|
||||
@@ -31,13 +31,6 @@ public class UserController(UserService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/role")]
|
||||
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignRoleAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/scope")]
|
||||
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Distribution.Features.User;
|
||||
|
||||
public record CreateUserRequest(
|
||||
string Username,
|
||||
[EmailAddress] string Username,
|
||||
string Password,
|
||||
string FullName,
|
||||
Guid RoleId,
|
||||
Guid? ReportsToUserId);
|
||||
|
||||
public record UpdateUserRequest(string FullName);
|
||||
public record UpdateUserRequest(
|
||||
string FullName,
|
||||
string? Username = null,
|
||||
Guid? RoleId = null,
|
||||
string? CurrentPassword = null,
|
||||
string? NewPassword = null);
|
||||
|
||||
public record AssignUserRoleRequest(Guid RoleId);
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
using Distribution.Features.Auth;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Distribution.Features.User;
|
||||
|
||||
public class UserService(IUserRepository repo, IUnitOfWork uow)
|
||||
public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient authHex)
|
||||
{
|
||||
private readonly PasswordHasher<Models.User> _hasher = new();
|
||||
|
||||
private static UserResponse ToResponse(Models.User u) =>
|
||||
new(u.Id, u.Username, u.FullName, u.FK_RoleId, u.Role?.Name ?? string.Empty,
|
||||
u.FK_ReportsToUserId, u.IsActive, u.CreatedAt, u.UpdatedAt);
|
||||
@@ -33,12 +31,30 @@ public class UserService(IUserRepository repo, IUnitOfWork uow)
|
||||
FK_RoleId = request.RoleId,
|
||||
FK_ReportsToUserId = request.ReportsToUserId
|
||||
};
|
||||
user.PasswordHash = _hasher.HashPassword(user, request.Password);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(user);
|
||||
|
||||
var authResult = await authHex.CallAsync("registerUser", new
|
||||
{
|
||||
userId = user.Id,
|
||||
userName = request.Username,
|
||||
email = request.Username,
|
||||
fullname = request.FullName,
|
||||
password = request.Password,
|
||||
roleId = request.RoleId.ToString(),
|
||||
userTypeId = "00000001-0000-0000-0000-200000000000",
|
||||
sendCredentialsEmail = false
|
||||
}, reference: user.Id.ToString(), ct: ct);
|
||||
|
||||
if (!authResult.Success)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw new InvalidOperationException($"AuthHex registration failed: {authResult.Message}");
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
@@ -53,12 +69,52 @@ public class UserService(IUserRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
var user = await repo.GetByIdAsync(id);
|
||||
if (user is null || !user.IsActive) return null;
|
||||
|
||||
user.FullName = request.FullName;
|
||||
if (!string.IsNullOrWhiteSpace(request.Username))
|
||||
user.Username = request.Username;
|
||||
if (request.RoleId.HasValue)
|
||||
user.FK_RoleId = request.RoleId.Value;
|
||||
user.UpdatedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(user);
|
||||
|
||||
var hasUsernameChange = !string.IsNullOrWhiteSpace(request.Username);
|
||||
var hasRoleChange = request.RoleId.HasValue;
|
||||
|
||||
if (hasUsernameChange || hasRoleChange)
|
||||
{
|
||||
var payload = new Dictionary<string, object> { ["userId"] = user.Id.ToString() };
|
||||
if (hasUsernameChange) payload["userName"] = request.Username!;
|
||||
if (hasRoleChange) payload["roleCode"] = request.RoleId!.Value.ToString();
|
||||
|
||||
var authResult = await authHex.CallAsync("UpdateUser", payload, reference: user.Id.ToString(), ct: ct);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw new InvalidOperationException($"AuthHex update failed: {authResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CurrentPassword) && !string.IsNullOrWhiteSpace(request.NewPassword))
|
||||
{
|
||||
var authResult = await authHex.CallAsync("ChangeUserPassword", new
|
||||
{
|
||||
userId = user.Id.ToString(),
|
||||
currentPassword = request.CurrentPassword,
|
||||
newPassword = request.NewPassword
|
||||
}, reference: user.Id.ToString(), ct: ct);
|
||||
|
||||
if (!authResult.Success)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw new InvalidOperationException($"AuthHex password change failed: {authResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
@@ -71,22 +127,9 @@ public class UserService(IUserRepository repo, IUnitOfWork uow)
|
||||
|
||||
public async Task<UserResponse?> AssignRoleAsync(Guid id, AssignUserRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await repo.GetByIdAsync(id);
|
||||
if (user is null || !user.IsActive) return null;
|
||||
user.FK_RoleId = request.RoleId;
|
||||
user.UpdatedAt = DateTime.Now;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(user);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(user);
|
||||
return await UpdateAsync(id, new UpdateUserRequest(
|
||||
FullName: (await repo.GetByIdAsync(id))?.FullName ?? string.Empty,
|
||||
RoleId: request.RoleId), ct);
|
||||
}
|
||||
|
||||
public async Task<bool> AssignScopeAsync(Guid id, AssignUserScopeRequest request, CancellationToken ct = default)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user