Compare commits
109 Commits
fix/auth_layer
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c775146b64 | |||
| 6e4bebb89b | |||
| 932d09c135 | |||
| 455ce699ad | |||
| 7d7e9f1318 | |||
| 87e3112a9a | |||
| 801276559d | |||
| b1bda1448e | |||
| 58247a1a0b | |||
| 3a503abe6a | |||
| 855eb44118 | |||
| 33dc4adce2 | |||
| 5b2b39b7bf | |||
| e13ec0f109 | |||
| 2c39b34c5f | |||
| 99832df134 | |||
| 2be9d9b396 | |||
| a12e170205 | |||
| fdfca7cf5e | |||
| e84ce29064 | |||
| 2be2928ad1 | |||
| 872fd42c4a | |||
| 39ad424d21 | |||
| 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 | |||
| 5d18aba1f9 | |||
| 5828879ddc | |||
| d75a43d45f | |||
| 728f2a8b17 | |||
| 1b66d5d767 | |||
| c5a0992b5d | |||
| 09d86835a2 | |||
| d1d2d55352 | |||
| c626f56cdb | |||
| 263e2ea9c0 | |||
| 5c37f02f19 | |||
| 6b3f28d3ae | |||
| e5f6aa15b9 | |||
| 4e12b03e4b | |||
| f5967497be | |||
| 4110e2fcfc | |||
| c52eaabec4 | |||
| 37f4a88014 | |||
| 2d365cd906 | |||
| c68c6e1ab9 | |||
| ddc3ecd3e6 | |||
| d7b6f29200 | |||
| 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<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
public DbSet<Invoice> Invoices { 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<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
@@ -65,6 +68,20 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||||
public DbSet<SysConfig> SysConfigs { 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<ExpenseCategory> ExpenseCategories { get; set; }
|
||||||
|
public DbSet<ExpenseVendor> ExpenseVendors { get; set; }
|
||||||
|
public DbSet<Expense> Expenses { get; set; }
|
||||||
|
public DbSet<ExpenseAttachment> ExpenseAttachments { get; set; }
|
||||||
|
public DbSet<RecurringExpenseTemplate> RecurringExpenseTemplates { get; set; }
|
||||||
|
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -166,6 +183,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ShopPOId)
|
.HasForeignKey(i => i.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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>()
|
modelBuilder.Entity<Invoice>()
|
||||||
.HasMany(i => i.Payments)
|
.HasMany(i => i.Payments)
|
||||||
.WithOne(p => p.Invoice)
|
.WithOne(p => p.Invoice)
|
||||||
@@ -594,7 +659,247 @@ 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 }
|
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 });
|
||||||
|
|
||||||
|
// --- Expense ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.HasOne(c => c.Parent)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_ParentCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.Property(c => c.Name).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.Property(c => c.Code).HasMaxLength(50);
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.HasIndex(c => new { c.FK_ParentCategoryId, c.Name }).IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseVendor>()
|
||||||
|
.Property(v => v.Name).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<ExpenseVendor>()
|
||||||
|
.HasIndex(v => v.Name).IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Category).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Warehouse).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Vendor).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_VendorId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.BankAccount).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_BankAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.ChequePage).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_ChequePageId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.RecurringTemplate).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_RecurringTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.Property(e => e.Payee).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.Property(e => e.BillRef).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasIndex(e => new { e.Status, e.BillDate });
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.HasOne(a => a.Expense).WithMany(e => e.Attachments)
|
||||||
|
.HasForeignKey(a => a.FK_ExpenseId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.OriginalFileName).HasMaxLength(260);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.StoredPath).HasMaxLength(500);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.ContentType).HasMaxLength(100);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Category).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Warehouse).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Vendor).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_VendorId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.Property(t => t.Name).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasIndex(t => new { t.IsPaused, t.NextRunDate });
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
|
Seed.ExpenseSeed.Apply(modelBuilder);
|
||||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||||
Seed.OperationsDataSeed.Apply(modelBuilder);
|
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||||
Seed.ProcurementSeed.Apply(modelBuilder);
|
Seed.ProcurementSeed.Apply(modelBuilder);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class ExpenseSeed
|
||||||
|
{
|
||||||
|
private static readonly DateTime SeedDate = new(2026, 10, 21);
|
||||||
|
|
||||||
|
// Top-level categories
|
||||||
|
private static readonly Guid CatSalaries = new("30000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid CatUtilities = new("30000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid CatRent = new("30000000-0000-0000-0000-000000000003");
|
||||||
|
private static readonly Guid CatVehicle = new("30000000-0000-0000-0000-000000000004");
|
||||||
|
private static readonly Guid CatOffice = new("30000000-0000-0000-0000-000000000005");
|
||||||
|
private static readonly Guid CatBankFees = new("30000000-0000-0000-0000-000000000006");
|
||||||
|
private static readonly Guid CatMisc = new("30000000-0000-0000-0000-000000000007");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ExpenseCategory>().HasData(
|
||||||
|
// Top-level
|
||||||
|
new ExpenseCategory { Id = CatSalaries, Name = "Salaries", Code = "SAL", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatUtilities, Name = "Utilities", Code = "UTL", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatRent, Name = "Rent", Code = "RNT", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatVehicle, Name = "Vehicle", Code = "VEH", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatOffice, Name = "Office", Code = "OFF", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatBankFees, Name = "Bank Fees", Code = "BNK", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatMisc, Name = "Miscellaneous", Code = "MSC", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Salaries
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000101"), Name = "Basic Salary", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000102"), Name = "Bonus", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000103"), Name = "Overtime", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Utilities
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000201"), Name = "Electricity", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000202"), Name = "Water", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000203"), Name = "Internet", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Rent
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000301"), Name = "Warehouse Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000302"), Name = "Office Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Vehicle
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000401"), Name = "Fuel", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000402"), Name = "Maintenance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000403"), Name = "Insurance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Office
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000501"), Name = "Stationery", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000502"), Name = "Cleaning", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Bank Fees
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000601"), Name = "Service Charges", FK_ParentCategoryId = CatBankFees, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Misc
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000701"), Name = "Other", FK_ParentCategoryId = CatMisc, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,13 @@ public static class RoleAndPermissionSeed
|
|||||||
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
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 PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||||
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||||
|
private static readonly Guid PermExpenseView = new("20000000-0000-0000-0000-000000000051");
|
||||||
|
private static readonly Guid PermExpenseCreate = new("20000000-0000-0000-0000-000000000052");
|
||||||
|
private static readonly Guid PermExpensePost = new("20000000-0000-0000-0000-000000000053");
|
||||||
|
private static readonly Guid PermExpensePay = new("20000000-0000-0000-0000-000000000054");
|
||||||
|
private static readonly Guid PermExpenseVoid = new("20000000-0000-0000-0000-000000000055");
|
||||||
|
private static readonly Guid PermExpenseManageCats = new("20000000-0000-0000-0000-000000000056");
|
||||||
|
private static readonly Guid PermExpenseManageRec = new("20000000-0000-0000-0000-000000000057");
|
||||||
|
|
||||||
public static void Apply(ModelBuilder modelBuilder)
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -134,7 +141,14 @@ public static class RoleAndPermissionSeed
|
|||||||
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", 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 = 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 = 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 }
|
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseView, Code = "expense.view", Description = "View expenses, categories, vendors and recurring templates", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseCreate, Code = "expense.create", Description = "Create and edit draft expenses", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpensePost, Code = "expense.post", Description = "Post a draft expense (approve it)", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpensePay, Code = "expense.pay", Description = "Pay a posted expense (debit a bank account or issue a cheque)", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseVoid, Code = "expense.void", Description = "Void an expense", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseManageCats, Code = "expense.manage_categories", Description = "Manage expense categories, titles and vendors", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseManageRec, Code = "expense.manage_recurring", Description = "Manage recurring expense templates", CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// SuperAdmin gets every permission
|
// SuperAdmin gets every permission
|
||||||
@@ -191,7 +205,14 @@ public static class RoleAndPermissionSeed
|
|||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, 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 = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, 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 }
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseCreate, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePost, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePay, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseVoid, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageCats, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageRec, CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,3 +1,4 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -6,7 +7,7 @@ namespace Distribution.Features.DeliveryRide;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api")]
|
[Route("api")]
|
||||||
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
||||||
[Authorize("delivery-task.manage")]
|
[Authorize("delivery-task.manage")]
|
||||||
@@ -68,7 +69,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.ConfirmAsync(id, ct);
|
var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.LoadingEvent;
|
using Distribution.Features.LoadingEvent;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
@@ -12,9 +13,19 @@ public class DeliveryTaskService(
|
|||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IShopPORepository shopPORepo,
|
IShopPORepository shopPORepo,
|
||||||
ILoadingSheetRepository loadingSheetRepo,
|
ILoadingSheetRepository loadingSheetRepo,
|
||||||
IStockRepository stockRepo,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
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) =>
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
new(t.Id,
|
new(t.Id,
|
||||||
t.FK_RideId,
|
t.FK_RideId,
|
||||||
@@ -134,7 +145,7 @@ public class DeliveryTaskService(
|
|||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
|
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var task = await repo.GetByIdAsync(taskId)
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
?? throw new KeyNotFoundException("Delivery task not found");
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
@@ -166,29 +177,12 @@ public class DeliveryTaskService(
|
|||||||
{
|
{
|
||||||
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
|
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
|
||||||
{
|
{
|
||||||
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
|
||||||
var availableQuantity = vehicleBalance?.Quantity ?? 0;
|
|
||||||
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
|
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
|
||||||
|
|
||||||
if (availableQuantity < item.QuantityFulfilled)
|
await mover.MoveAsync(item.FK_ItemId, itemName,
|
||||||
throw new InvalidOperationException(
|
vehicleId, LocationType.Vehicle,
|
||||||
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
|
StockService.ExternalLocationId, LocationType.External,
|
||||||
|
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
|
||||||
vehicleBalance!.Quantity -= item.QuantityFulfilled;
|
|
||||||
vehicleBalance.UpdatedAt = DateTime.Now;
|
|
||||||
stockRepo.UpdateBalance(vehicleBalance);
|
|
||||||
|
|
||||||
await stockRepo.AddMovementAsync(new StockMovement
|
|
||||||
{
|
|
||||||
FK_ItemId = item.FK_ItemId,
|
|
||||||
FromLocationId = vehicleId,
|
|
||||||
FromLocationType = LocationType.Vehicle,
|
|
||||||
ToLocationId = StockService.ExternalLocationId,
|
|
||||||
ToLocationType = LocationType.External,
|
|
||||||
Quantity = item.QuantityFulfilled,
|
|
||||||
Type = MovementType.Sale,
|
|
||||||
ReferenceId = task.FK_ShopPOId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.Update(task);
|
repo.Update(task);
|
||||||
@@ -198,6 +192,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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);
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -221,6 +222,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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);
|
return ToResponse(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class ExpenseAttachmentController(ExpenseAttachmentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("api/expenses/{expenseId:guid}/attachments")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<IActionResult> Upload(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UploadAsync(expenseId, file, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/expense-attachments/{id:guid}/download")]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.DownloadAsync(id, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.Stream, result.Value.ContentType, result.Value.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("api/expense-attachments/{id:guid}")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||||
|
await service.DeleteAsync(id, ct) ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseAttachmentService(
|
||||||
|
AppDBContext context,
|
||||||
|
IConfiguration config,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private const long MaxBytes = 10 * 1024 * 1024;
|
||||||
|
private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
|
||||||
|
private static readonly string[] AllowedContentTypes =
|
||||||
|
["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
private string AttachmentsRoot =>
|
||||||
|
config["Expense:AttachmentsRoot"]
|
||||||
|
?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured");
|
||||||
|
|
||||||
|
public async Task<ExpenseAttachmentResponse?> UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct);
|
||||||
|
if (expense is null) return null;
|
||||||
|
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
throw new InvalidOperationException("File is empty");
|
||||||
|
if (file.Length > MaxBytes)
|
||||||
|
throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit");
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
if (!AllowedExtensions.Contains(ext))
|
||||||
|
throw new InvalidOperationException($"File type '{ext}' is not allowed");
|
||||||
|
if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
|
||||||
|
throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2"));
|
||||||
|
var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir);
|
||||||
|
Directory.CreateDirectory(absoluteDir);
|
||||||
|
|
||||||
|
var storedName = $"{Guid.NewGuid()}{ext}";
|
||||||
|
var absolutePath = Path.Combine(absoluteDir, storedName);
|
||||||
|
|
||||||
|
await using (var stream = File.Create(absolutePath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachment = new Models.ExpenseAttachment
|
||||||
|
{
|
||||||
|
FK_ExpenseId = expenseId,
|
||||||
|
OriginalFileName = file.FileName,
|
||||||
|
StoredPath = absolutePath,
|
||||||
|
ContentType = file.ContentType,
|
||||||
|
SizeBytes = file.Length,
|
||||||
|
UploadedByUserId = userId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.ExpenseAttachments.AddAsync(attachment, ct);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
try { File.Delete(absolutePath); } catch { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName,
|
||||||
|
attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||||
|
if (a is null) return null;
|
||||||
|
if (!File.Exists(a.StoredPath)) return null;
|
||||||
|
|
||||||
|
var stream = File.OpenRead(a.StoredPath);
|
||||||
|
return (stream, a.ContentType, a.OriginalFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid attachmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||||
|
if (a is null) return false;
|
||||||
|
|
||||||
|
a.IsActive = false;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
// Best-effort file removal — keep DB soft-delete even if the file is gone.
|
||||||
|
try { File.Delete(a.StoredPath); } catch { }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expenses")]
|
||||||
|
public class ExpenseController(
|
||||||
|
ExpenseService service,
|
||||||
|
IPermissionService permissions,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
private async Task<bool> HasAsync(string code)
|
||||||
|
{
|
||||||
|
if (currentUser.RoleId is not { } roleId) return false;
|
||||||
|
return await permissions.HasPermissionAsync(roleId, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] ExpenseStatus? status,
|
||||||
|
[FromQuery] Guid? categoryId,
|
||||||
|
[FromQuery] Guid? warehouseId,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20) =>
|
||||||
|
Ok(await service.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create endpoint takes a required ?action= query to drive the 4 form buttons:
|
||||||
|
/// draft → Save draft; post → Save & post (requires expense.post); pay → Save & pay
|
||||||
|
/// (requires expense.post + expense.pay). Draft is the default.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> Create(
|
||||||
|
CreateExpenseRequest request,
|
||||||
|
[FromQuery] ExpenseAction action = ExpenseAction.Draft,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (action is ExpenseAction.Post or ExpenseAction.Pay && !await HasAsync("expense.post"))
|
||||||
|
return Forbid();
|
||||||
|
if (action == ExpenseAction.Pay && !await HasAsync("expense.pay"))
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
|
try { return Ok(await service.CreateAsync(request, action, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> UpdateDraft(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateDraftAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/post")]
|
||||||
|
[Authorize("expense.post")]
|
||||||
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PostAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/pay")]
|
||||||
|
[Authorize("expense.pay")]
|
||||||
|
public async Task<IActionResult> Pay(Guid id, ExpensePaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PayAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/void")]
|
||||||
|
[Authorize("expense.void")]
|
||||||
|
public async Task<IActionResult> Void(Guid id, VoidExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.VoidAsync(id, request?.Reason, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public record ExpensePaymentRequest(
|
||||||
|
PaymentMethod Method,
|
||||||
|
Guid? BankAccountId,
|
||||||
|
Guid? ChequePageId);
|
||||||
|
|
||||||
|
public record CreateExpenseRequest(
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description,
|
||||||
|
/// <summary>Required when action = Pay. Ignored for Draft / Post.</summary>
|
||||||
|
ExpensePaymentRequest? Payment);
|
||||||
|
|
||||||
|
public record UpdateExpenseDraftRequest(
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
|
public record VoidExpenseRequest(string? Reason);
|
||||||
|
|
||||||
|
public record ExpenseAttachmentResponse(
|
||||||
|
Guid Id,
|
||||||
|
string OriginalFileName,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record ExpenseResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid CategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
Guid? ParentCategoryId,
|
||||||
|
string? ParentCategoryName,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
string? WarehouseName,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? VendorName,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description,
|
||||||
|
ExpenseStatus Status,
|
||||||
|
PaymentMethod? Method,
|
||||||
|
Guid? BankAccountId,
|
||||||
|
Guid? ChequePageId,
|
||||||
|
string? ChequeNo,
|
||||||
|
DateTime? PaidAt,
|
||||||
|
Guid RecordedByUserId,
|
||||||
|
Guid? PostedByUserId,
|
||||||
|
Guid? PaidByUserId,
|
||||||
|
Guid? VoidedByUserId,
|
||||||
|
string? VoidReason,
|
||||||
|
Guid? RecurringTemplateId,
|
||||||
|
List<ExpenseAttachmentResponse> Attachments,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PagedExpensesResponse(List<ExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseRepository(AppDBContext context) : IExpenseRepository
|
||||||
|
{
|
||||||
|
private IQueryable<Models.Expense> Base() =>
|
||||||
|
context.Expenses
|
||||||
|
.Include(e => e.Category!).ThenInclude(c => c.Parent)
|
||||||
|
.Include(e => e.Warehouse)
|
||||||
|
.Include(e => e.Vendor)
|
||||||
|
.Include(e => e.ChequePage)
|
||||||
|
.Include(e => e.Attachments.Where(a => a.IsActive));
|
||||||
|
|
||||||
|
public async Task<Models.Expense?> GetByIdAsync(Guid id) =>
|
||||||
|
await Base().FirstOrDefaultAsync(e => e.Id == id && e.IsActive);
|
||||||
|
|
||||||
|
private IQueryable<Models.Expense> Filtered(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||||
|
Base()
|
||||||
|
.Where(e => e.IsActive)
|
||||||
|
.Where(e => !status.HasValue || e.Status == status)
|
||||||
|
.Where(e => !categoryId.HasValue
|
||||||
|
|| e.FK_CategoryId == categoryId
|
||||||
|
|| e.Category!.FK_ParentCategoryId == categoryId)
|
||||||
|
.Where(e => !warehouseId.HasValue || e.FK_WarehouseId == warehouseId)
|
||||||
|
.Where(e => !from.HasValue || e.BillDate >= from)
|
||||||
|
.Where(e => !to.HasValue || e.BillDate <= to);
|
||||||
|
|
||||||
|
public async Task<List<Models.Expense>> ListAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||||
|
int page, int pageSize) =>
|
||||||
|
await Filtered(status, categoryId, warehouseId, from, to)
|
||||||
|
.OrderByDescending(e => e.BillDate)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||||
|
await Filtered(status, categoryId, warehouseId, from, to).CountAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Expense entity) =>
|
||||||
|
await context.Expenses.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.Expense entity) =>
|
||||||
|
context.Expenses.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Features.BankAccount;
|
||||||
|
using Distribution.Features.ChequeBook;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseService(
|
||||||
|
AppDBContext context,
|
||||||
|
IExpenseRepository repo,
|
||||||
|
IBankAccountRepository bankAccountRepo,
|
||||||
|
IChequeBookRepository chequeBookRepo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) =>
|
||||||
|
new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt);
|
||||||
|
|
||||||
|
private static ExpenseResponse ToResponse(Models.Expense e) =>
|
||||||
|
new(e.Id,
|
||||||
|
e.FK_CategoryId, e.Category?.Name ?? string.Empty,
|
||||||
|
e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name,
|
||||||
|
e.FK_WarehouseId, e.Warehouse?.Name,
|
||||||
|
e.Amount, e.BillDate, e.DueDate, e.PeriodCovered,
|
||||||
|
e.FK_VendorId, e.Vendor?.Name,
|
||||||
|
e.Payee, e.BillRef, e.Description,
|
||||||
|
e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo,
|
||||||
|
e.PaidAt,
|
||||||
|
e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason,
|
||||||
|
e.FK_RecurringTemplateId,
|
||||||
|
e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(),
|
||||||
|
e.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedExpensesResponse> ListAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||||
|
int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||||
|
|
||||||
|
var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize);
|
||||||
|
var total = await repo.CountAsync(status, categoryId, warehouseId, from, to);
|
||||||
|
return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> GetAsync(Guid id)
|
||||||
|
{
|
||||||
|
var e = await repo.GetByIdAsync(id);
|
||||||
|
return e is null ? null : ToResponse(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse> CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ValidateBase(request);
|
||||||
|
|
||||||
|
var category = await context.ExpenseCategories.Include(c => c.Parent)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||||
|
?? throw new InvalidOperationException("Category not found");
|
||||||
|
if (category.FK_ParentCategoryId is null)
|
||||||
|
throw new InvalidOperationException("Pick a specific title under a category, not the category itself");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var entity = new Models.Expense
|
||||||
|
{
|
||||||
|
FK_CategoryId = request.CategoryId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
BillDate = request.BillDate.Date,
|
||||||
|
DueDate = request.DueDate?.Date,
|
||||||
|
PeriodCovered = request.PeriodCovered?.Date,
|
||||||
|
FK_VendorId = request.VendorId,
|
||||||
|
Payee = request.Payee,
|
||||||
|
BillRef = request.BillRef,
|
||||||
|
Description = request.Description,
|
||||||
|
RecordedByUserId = userId,
|
||||||
|
Status = ExpenseStatus.Draft
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(entity);
|
||||||
|
|
||||||
|
if (action is ExpenseAction.Post or ExpenseAction.Pay)
|
||||||
|
{
|
||||||
|
entity.Status = ExpenseStatus.Posted;
|
||||||
|
entity.PostedByUserId = userId;
|
||||||
|
entity.PostedAt = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == ExpenseAction.Pay)
|
||||||
|
{
|
||||||
|
if (request.Payment is null)
|
||||||
|
throw new InvalidOperationException("Payment details are required when action = Pay");
|
||||||
|
await ApplyPaymentAsync(entity, request.Payment, userId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(entity.Id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Only Draft expenses can be edited");
|
||||||
|
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
|
||||||
|
entity.FK_CategoryId = request.CategoryId;
|
||||||
|
entity.FK_WarehouseId = request.WarehouseId;
|
||||||
|
entity.Amount = request.Amount;
|
||||||
|
entity.BillDate = request.BillDate.Date;
|
||||||
|
entity.DueDate = request.DueDate?.Date;
|
||||||
|
entity.PeriodCovered = request.PeriodCovered?.Date;
|
||||||
|
entity.FK_VendorId = request.VendorId;
|
||||||
|
entity.Payee = request.Payee;
|
||||||
|
entity.BillRef = request.BillRef;
|
||||||
|
entity.Description = request.Description;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> PostAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Only Draft expenses can be posted");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
entity.Status = ExpenseStatus.Posted;
|
||||||
|
entity.PostedByUserId = userId;
|
||||||
|
entity.PostedAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Posted)
|
||||||
|
throw new InvalidOperationException("Only Posted expenses can be paid");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyPaymentAsync(entity, payment, userId, ct);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> VoidAsync(Guid id, string? reason, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status == ExpenseStatus.Void)
|
||||||
|
throw new InvalidOperationException("Already voided");
|
||||||
|
if (entity.Status == ExpenseStatus.Paid)
|
||||||
|
throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
entity.Status = ExpenseStatus.Void;
|
||||||
|
entity.VoidedByUserId = userId;
|
||||||
|
entity.VoidedAt = DateTime.Now;
|
||||||
|
entity.VoidReason = reason;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void ValidateBase(CreateExpenseRequest request)
|
||||||
|
{
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date)
|
||||||
|
throw new InvalidOperationException("Due date cannot be earlier than bill date");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account);
|
||||||
|
/// Cheque → consume an Unused ChequePage from an Active cheque book.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (payment.Method == PaymentMethod.Credit)
|
||||||
|
throw new InvalidOperationException("Credit is not a valid method for expense payments");
|
||||||
|
|
||||||
|
if (payment.Method == PaymentMethod.Cash)
|
||||||
|
{
|
||||||
|
if (payment.BankAccountId is null)
|
||||||
|
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||||
|
|
||||||
|
var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value)
|
||||||
|
?? throw new InvalidOperationException("BankAccountId does not reference a valid account");
|
||||||
|
if (account.IsActive is false)
|
||||||
|
throw new InvalidOperationException("The selected account is inactive");
|
||||||
|
|
||||||
|
entity.FK_BankAccountId = account.Id;
|
||||||
|
}
|
||||||
|
else // Cheque
|
||||||
|
{
|
||||||
|
if (payment.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 == payment.ChequePageId.Value, ct)
|
||||||
|
?? 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.Other;
|
||||||
|
page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense";
|
||||||
|
page.IssueDate = DateTime.Now;
|
||||||
|
page.Amount = entity.Amount;
|
||||||
|
page.CurrencyCode = "LKR";
|
||||||
|
page.PrintedAt = DateTime.Now;
|
||||||
|
|
||||||
|
chequeBookRepo.UpdatePage(page);
|
||||||
|
entity.FK_ChequePageId = page.Id;
|
||||||
|
entity.ChequePage = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Method = payment.Method;
|
||||||
|
entity.Status = ExpenseStatus.Paid;
|
||||||
|
entity.PaidByUserId = userId;
|
||||||
|
entity.PaidAt = DateTime.Now;
|
||||||
|
|
||||||
|
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||||
|
// Debit Expense (category)
|
||||||
|
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public interface IExpenseRepository
|
||||||
|
{
|
||||||
|
Task<Models.Expense?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Expense>> ListAsync(
|
||||||
|
ExpenseStatus? status,
|
||||||
|
Guid? categoryId,
|
||||||
|
Guid? warehouseId,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
int page,
|
||||||
|
int pageSize);
|
||||||
|
Task<int> CountAsync(
|
||||||
|
ExpenseStatus? status,
|
||||||
|
Guid? categoryId,
|
||||||
|
Guid? warehouseId,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to);
|
||||||
|
Task AddAsync(Models.Expense entity);
|
||||||
|
void Update(Models.Expense entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-categories")]
|
||||||
|
public class ExpenseCategoryController(ExpenseCategoryService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Tree() => Ok(await service.GetTreeAsync());
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Create(CreateExpenseCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public record ExpenseCategoryResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
Guid? ParentCategoryId,
|
||||||
|
bool IsActive,
|
||||||
|
List<ExpenseCategoryResponse> Children);
|
||||||
|
|
||||||
|
public record CreateExpenseCategoryRequest(
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
Guid? ParentCategoryId);
|
||||||
|
|
||||||
|
public record UpdateExpenseCategoryRequest(
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public class ExpenseCategoryRepository(AppDBContext context) : IExpenseCategoryRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.ExpenseCategory?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.ExpenseCategory>> GetAllAsync() =>
|
||||||
|
await context.ExpenseCategories.OrderBy(c => c.FK_ParentCategoryId).ThenBy(c => c.Name).ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.ExpenseCategory entity) =>
|
||||||
|
await context.ExpenseCategories.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.ExpenseCategory entity) =>
|
||||||
|
context.ExpenseCategories.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public class ExpenseCategoryService(IExpenseCategoryRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
public async Task<List<ExpenseCategoryResponse>> GetTreeAsync()
|
||||||
|
{
|
||||||
|
var all = await repo.GetAllAsync();
|
||||||
|
var byParent = all.ToLookup(c => c.FK_ParentCategoryId);
|
||||||
|
|
||||||
|
return all
|
||||||
|
.Where(c => c.FK_ParentCategoryId is null)
|
||||||
|
.Select(c => Map(c, byParent))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseCategoryResponse Map(Models.ExpenseCategory c, ILookup<Guid?, Models.ExpenseCategory> byParent) =>
|
||||||
|
new(c.Id, c.Name, c.Code, c.FK_ParentCategoryId, c.IsActive,
|
||||||
|
byParent[c.Id].Select(child => Map(child, byParent)).ToList());
|
||||||
|
|
||||||
|
public async Task<ExpenseCategoryResponse> CreateAsync(CreateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
throw new InvalidOperationException("Name is required");
|
||||||
|
|
||||||
|
if (request.ParentCategoryId is { } parentId)
|
||||||
|
{
|
||||||
|
var parent = await repo.GetByIdAsync(parentId)
|
||||||
|
?? throw new InvalidOperationException("Parent category not found");
|
||||||
|
if (parent.FK_ParentCategoryId is not null)
|
||||||
|
throw new InvalidOperationException("Only two levels are allowed — cannot nest under a title");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new Models.ExpenseCategory
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim(),
|
||||||
|
FK_ParentCategoryId = request.ParentCategoryId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseCategoryResponse?> UpdateAsync(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
entity.Name = request.Name.Trim();
|
||||||
|
entity.Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim();
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public interface IExpenseCategoryRepository
|
||||||
|
{
|
||||||
|
Task<Models.ExpenseCategory?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.ExpenseCategory>> GetAllAsync();
|
||||||
|
Task AddAsync(Models.ExpenseCategory entity);
|
||||||
|
void Update(Models.ExpenseCategory entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-vendors")]
|
||||||
|
public class ExpenseVendorController(ExpenseVendorService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50) =>
|
||||||
|
Ok(await service.ListAsync(search, page, pageSize));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Create(CreateExpenseVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public record ExpenseVendorResponse(Guid Id, string Name, string? ContactInfo, bool IsActive);
|
||||||
|
|
||||||
|
public record CreateExpenseVendorRequest(string Name, string? ContactInfo);
|
||||||
|
|
||||||
|
public record UpdateExpenseVendorRequest(string Name, string? ContactInfo, bool IsActive);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public class ExpenseVendorRepository(AppDBContext context) : IExpenseVendorRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.ExpenseVendor?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.ExpenseVendors.FirstOrDefaultAsync(v => v.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize) =>
|
||||||
|
await context.ExpenseVendors
|
||||||
|
.Where(v => v.IsActive && (search == null || v.Name.Contains(search)))
|
||||||
|
.OrderBy(v => v.Name)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync(string? search) =>
|
||||||
|
await context.ExpenseVendors
|
||||||
|
.CountAsync(v => v.IsActive && (search == null || v.Name.Contains(search)));
|
||||||
|
|
||||||
|
public async Task<bool> NameExistsAsync(string name, Guid? excludeId) =>
|
||||||
|
await context.ExpenseVendors.AnyAsync(v => v.Name == name && (excludeId == null || v.Id != excludeId));
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.ExpenseVendor entity) =>
|
||||||
|
await context.ExpenseVendors.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.ExpenseVendor entity) =>
|
||||||
|
context.ExpenseVendors.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public record PagedExpenseVendorsResponse(List<ExpenseVendorResponse> Items, int Page, int PageSize, int Total);
|
||||||
|
|
||||||
|
public class ExpenseVendorService(IExpenseVendorRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static ExpenseVendorResponse Map(Models.ExpenseVendor v) =>
|
||||||
|
new(v.Id, v.Name, v.ContactInfo, v.IsActive);
|
||||||
|
|
||||||
|
public async Task<PagedExpenseVendorsResponse> ListAsync(string? search, int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 50;
|
||||||
|
|
||||||
|
search = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
|
||||||
|
var items = await repo.ListAsync(search, page, pageSize);
|
||||||
|
var total = await repo.CountAsync(search);
|
||||||
|
return new PagedExpenseVendorsResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseVendorResponse> CreateAsync(CreateExpenseVendorRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var name = (request.Name ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||||
|
if (await repo.NameExistsAsync(name, null))
|
||||||
|
throw new InvalidOperationException("A vendor with this name already exists");
|
||||||
|
|
||||||
|
var entity = new Models.ExpenseVendor { Name = name, ContactInfo = request.ContactInfo };
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseVendorResponse?> UpdateAsync(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
var name = (request.Name ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||||
|
if (await repo.NameExistsAsync(name, id))
|
||||||
|
throw new InvalidOperationException("A vendor with this name already exists");
|
||||||
|
|
||||||
|
entity.Name = name;
|
||||||
|
entity.ContactInfo = request.ContactInfo;
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public interface IExpenseVendorRepository
|
||||||
|
{
|
||||||
|
Task<Models.ExpenseVendor?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize);
|
||||||
|
Task<int> CountAsync(string? search);
|
||||||
|
Task<bool> NameExistsAsync(string name, Guid? excludeId);
|
||||||
|
Task AddAsync(Models.ExpenseVendor entity);
|
||||||
|
void Update(Models.ExpenseVendor entity);
|
||||||
|
}
|
||||||
@@ -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<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<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||||
|
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||||
Task AddAsync(Models.Invoice invoice);
|
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);
|
void Update(Models.Invoice invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,52 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
|||||||
return result is null ? NotFound() : Ok(result);
|
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")]
|
[HttpPost("{id:guid}/print")]
|
||||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
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;
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = 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 FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = 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(
|
public record InvoiceResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ShopPOId,
|
Guid ShopPOId,
|
||||||
|
Guid StoreId,
|
||||||
string StoreName,
|
string StoreName,
|
||||||
InvoiceStatus Status,
|
InvoiceStatus Status,
|
||||||
PaymentStatus PaymentStatus,
|
PaymentStatus PaymentStatus,
|
||||||
@@ -19,7 +56,11 @@ public record InvoiceResponse(
|
|||||||
DateTime? FinalizedAt,
|
DateTime? FinalizedAt,
|
||||||
Guid? FinalizedBy,
|
Guid? FinalizedBy,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
|
decimal DiscountAmount,
|
||||||
|
decimal PaidAmount,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceItemResponse> Items,
|
||||||
|
List<ShopPODiscountResponse>? Discounts = null);
|
||||||
|
|
||||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
await context.Invoices
|
await context.Invoices
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
.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))
|
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||||
.FirstOrDefaultAsync(i => i.Id == id && i.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) =>
|
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
|
||||||
await context.Invoices
|
await context.Invoices
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||||
|
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||||
.Where(i => i.IsActive
|
.Where(i => i.IsActive
|
||||||
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
||||||
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
|
&& (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)
|
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||||
.ToListAsync();
|
.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) =>
|
public async Task AddAsync(Models.Invoice invoice) =>
|
||||||
await context.Invoices.AddAsync(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) =>
|
public void Update(Models.Invoice invoice) =>
|
||||||
context.Invoices.Update(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.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -7,21 +9,126 @@ namespace Distribution.Features.Invoice;
|
|||||||
|
|
||||||
public class InvoiceService(
|
public class InvoiceService(
|
||||||
IInvoiceRepository repo,
|
IInvoiceRepository repo,
|
||||||
|
IShopPORepository shopPoRepo,
|
||||||
|
SchemeService schemeService,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser)
|
ICurrentUserService currentUser)
|
||||||
{
|
{
|
||||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||||
new(i.Id, i.FK_ShopPOId,
|
new(i.Id, i.FK_ShopPOId,
|
||||||
|
i.ShopPO?.FK_StoreId ?? Guid.Empty,
|
||||||
i.ShopPO?.Store?.StoreName ?? string.Empty,
|
i.ShopPO?.Store?.StoreName ?? string.Empty,
|
||||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||||
i.PrintedAt, i.PrintedBy,
|
i.PrintedAt, i.PrintedBy,
|
||||||
i.FinalizedAt, i.FinalizedBy,
|
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)
|
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(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)
|
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);
|
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)
|
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(id)
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
@@ -47,12 +279,21 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Draft)
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
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.Status = InvoiceStatus.Printed;
|
||||||
invoice.PrintedAt = DateTime.Now;
|
invoice.PrintedAt = DateTime.Now;
|
||||||
invoice.PrintedBy = printedBy;
|
invoice.PrintedBy = printedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
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; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
@@ -66,31 +307,37 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Printed)
|
if (invoice.Status != InvoiceStatus.Printed)
|
||||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
var adjustments = request.Adjustments ?? [];
|
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();
|
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||||
decimal total = 0;
|
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||||
foreach (var item in poItems)
|
|
||||||
|
item.IsRemoved = adjustment.IsRemoved;
|
||||||
|
if (!adjustment.IsRemoved)
|
||||||
{
|
{
|
||||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
item.Quantity = adjustment.AdjustedQuantity;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
invoice.TotalAmount = total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
await ApplyTotalsAsync(invoice, items);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Finalized;
|
invoice.Status = InvoiceStatus.Finalized;
|
||||||
invoice.FinalizedAt = DateTime.Now;
|
invoice.FinalizedAt = DateTime.Now;
|
||||||
invoice.FinalizedBy = finalizedBy;
|
invoice.FinalizedBy = finalizedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
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; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace Distribution.Features.LoadingEvent;
|
|||||||
public class LoadingSheetService(
|
public class LoadingSheetService(
|
||||||
ILoadingSheetRepository repo,
|
ILoadingSheetRepository repo,
|
||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IStockRepository stockRepo,
|
StockMover mover,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize)
|
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize)
|
||||||
@@ -154,59 +154,14 @@ public class LoadingSheetService(
|
|||||||
|
|
||||||
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
|
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
|
||||||
{
|
{
|
||||||
// Vehicle balances created during this confirm are only tracked, not yet saved, so a
|
|
||||||
// later row for the same item would not find them via the repository and would try to
|
|
||||||
// insert a duplicate (item, vehicle) balance.
|
|
||||||
var createdVehicleBalances = new Dictionary<Guid, StockBalance>();
|
|
||||||
|
|
||||||
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
|
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
|
||||||
{
|
{
|
||||||
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
|
var label = $"{item.Item?.Name ?? item.FK_ItemId.ToString()} (warehouse {item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()})";
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
$"No stock found for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}'");
|
|
||||||
|
|
||||||
if (warehouseBalance.Quantity < item.QuantityDispatched)
|
await mover.MoveAsync(item.FK_ItemId, label,
|
||||||
throw new InvalidOperationException(
|
item.FK_WarehouseId, LocationType.Warehouse,
|
||||||
$"Insufficient stock for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}' " +
|
vehicleId, LocationType.Vehicle,
|
||||||
$"(available {warehouseBalance.Quantity}, needed {item.QuantityDispatched})");
|
item.QuantityDispatched, MovementType.Load, sheetId, userId);
|
||||||
|
|
||||||
warehouseBalance.Quantity -= item.QuantityDispatched;
|
|
||||||
warehouseBalance.UpdatedAt = DateTime.Now;
|
|
||||||
stockRepo.UpdateBalance(warehouseBalance);
|
|
||||||
|
|
||||||
var vehicleBalance = createdVehicleBalances.GetValueOrDefault(item.FK_ItemId)
|
|
||||||
?? await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
|
||||||
if (vehicleBalance is null)
|
|
||||||
{
|
|
||||||
var created = new StockBalance
|
|
||||||
{
|
|
||||||
FK_ItemId = item.FK_ItemId,
|
|
||||||
LocationId = vehicleId,
|
|
||||||
LocationType = LocationType.Vehicle,
|
|
||||||
Quantity = item.QuantityDispatched
|
|
||||||
};
|
|
||||||
createdVehicleBalances[item.FK_ItemId] = created;
|
|
||||||
await stockRepo.AddBalanceAsync(created);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
vehicleBalance.Quantity += item.QuantityDispatched;
|
|
||||||
vehicleBalance.UpdatedAt = DateTime.Now;
|
|
||||||
stockRepo.UpdateBalance(vehicleBalance);
|
|
||||||
}
|
|
||||||
|
|
||||||
await stockRepo.AddMovementAsync(new StockMovement
|
|
||||||
{
|
|
||||||
FK_ItemId = item.FK_ItemId,
|
|
||||||
FromLocationId = item.FK_WarehouseId,
|
|
||||||
FromLocationType = LocationType.Warehouse,
|
|
||||||
ToLocationId = vehicleId,
|
|
||||||
ToLocationType = LocationType.Vehicle,
|
|
||||||
Quantity = item.QuantityDispatched,
|
|
||||||
Type = MovementType.Load,
|
|
||||||
ReferenceId = sheetId,
|
|
||||||
CreatedBy = userId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
@@ -6,8 +6,14 @@ public record CreatePaymentRequest(
|
|||||||
decimal Amount,
|
decimal Amount,
|
||||||
PaymentMethod Method,
|
PaymentMethod Method,
|
||||||
EntryMode EntryMode = EntryMode.RealTime,
|
EntryMode EntryMode = EntryMode.RealTime,
|
||||||
|
// Cash: required. Cheque: optional (used as the deposit account hint if provided).
|
||||||
|
Guid? BankAccountId = null,
|
||||||
|
// Cheque: required.
|
||||||
string? ChequeNumber = null,
|
string? ChequeNumber = null,
|
||||||
DateTime? ChequeDate = null,
|
DateTime? ChequeDate = null,
|
||||||
|
// Cheque: optional metadata forwarded to the auto-created ReceivedCheque.
|
||||||
|
string? DrawerBankName = null,
|
||||||
|
string? AccountHolderName = null,
|
||||||
Guid? CollectedByUserId = null);
|
Guid? CollectedByUserId = null);
|
||||||
|
|
||||||
public record PaymentResponse(
|
public record PaymentResponse(
|
||||||
@@ -16,6 +22,8 @@ public record PaymentResponse(
|
|||||||
decimal Amount,
|
decimal Amount,
|
||||||
PaymentMethod Method,
|
PaymentMethod Method,
|
||||||
EntryMode EntryMode,
|
EntryMode EntryMode,
|
||||||
|
Guid? BankAccountId,
|
||||||
|
Guid? ReceivedChequeId,
|
||||||
string? ChequeNumber,
|
string? ChequeNumber,
|
||||||
DateTime? ChequeDate,
|
DateTime? ChequeDate,
|
||||||
DateTime CollectedAt,
|
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.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -8,11 +11,16 @@ namespace Distribution.Features.Payment;
|
|||||||
public class PaymentService(
|
public class PaymentService(
|
||||||
IPaymentRepository repo,
|
IPaymentRepository repo,
|
||||||
IInvoiceRepository invoiceRepo,
|
IInvoiceRepository invoiceRepo,
|
||||||
|
IBankAccountRepository bankAccountRepo,
|
||||||
|
IReceivedChequeRepository receivedChequeRepo,
|
||||||
|
IStoreCreditMovementRepository creditRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser)
|
ICurrentUserService currentUser)
|
||||||
{
|
{
|
||||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
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);
|
p.CollectedAt, p.CollectedBy, p.RecordedBy, p.CreatedAt);
|
||||||
|
|
||||||
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
|
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
|
||||||
@@ -28,13 +36,46 @@ public class PaymentService(
|
|||||||
{
|
{
|
||||||
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
|
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
|
||||||
if (invoice is null || !invoice.IsActive) return null;
|
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 &&
|
if (request.Method == PaymentMethod.Cheque &&
|
||||||
(string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
|
(string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
|
||||||
throw new InvalidOperationException("ChequeNumber and ChequeDate are required when Method is Cheque");
|
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
|
var recorderId = currentUser.UserId ?? request.CollectedByUserId
|
||||||
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
|
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
|
||||||
var collectedBy = request.CollectedByUserId ?? recorderId;
|
var collectedBy = request.CollectedByUserId ?? recorderId;
|
||||||
@@ -50,6 +91,7 @@ public class PaymentService(
|
|||||||
EntryMode = request.EntryMode,
|
EntryMode = request.EntryMode,
|
||||||
ChequeNumber = request.Method == PaymentMethod.Cheque ? request.ChequeNumber : null,
|
ChequeNumber = request.Method == PaymentMethod.Cheque ? request.ChequeNumber : null,
|
||||||
ChequeDate = request.Method == PaymentMethod.Cheque ? request.ChequeDate : null,
|
ChequeDate = request.Method == PaymentMethod.Cheque ? request.ChequeDate : null,
|
||||||
|
FK_BankAccountId = request.BankAccountId,
|
||||||
CollectedAt = DateTime.Now,
|
CollectedAt = DateTime.Now,
|
||||||
CollectedBy = collectedBy,
|
CollectedBy = collectedBy,
|
||||||
RecordedBy = recordedBy
|
RecordedBy = recordedBy
|
||||||
@@ -58,11 +100,48 @@ public class PaymentService(
|
|||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
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);
|
await repo.AddAsync(payment);
|
||||||
|
|
||||||
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
|
if (request.Method == PaymentMethod.Credit)
|
||||||
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
{
|
||||||
|
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
|
invoice.PaymentStatus = total == 0
|
||||||
? PaymentStatus.Pending
|
? PaymentStatus.Pending
|
||||||
: total < invoice.TotalAmount
|
: total < invoice.TotalAmount
|
||||||
@@ -70,6 +149,11 @@ public class PaymentService(
|
|||||||
: PaymentStatus.Paid;
|
: PaymentStatus.Paid;
|
||||||
|
|
||||||
invoiceRepo.Update(invoice);
|
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);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
return (rows, total);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||||
{
|
{
|
||||||
var lines =
|
var lines =
|
||||||
from li in db.ShopPOItems.AsNoTracking()
|
from li in db.InvoiceItems.AsNoTracking()
|
||||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
where li.IsActive && !li.IsRemoved
|
||||||
where inv.Status == InvoiceStatus.Finalized
|
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||||
select li;
|
select li;
|
||||||
|
|
||||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
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.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.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.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.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.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||||
{
|
{
|
||||||
var s = f.Search.Trim();
|
var s = f.Search.Trim();
|
||||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
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.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
.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.
|
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
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))
|
.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()
|
.Distinct()
|
||||||
.ToListAsync();
|
.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,
|
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);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
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)
|
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 })
|
return await SoldLines(f with { Search = null })
|
||||||
.Where(l => ids.Contains(l.FK_ItemId))
|
.Where(l => ids.Contains(l.FK_ItemId))
|
||||||
.GroupBy(l => 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);
|
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class PurchaseReturnService(
|
|||||||
IPurchaseReturnRepository repo,
|
IPurchaseReturnRepository repo,
|
||||||
IPoRepository poRepo,
|
IPoRepository poRepo,
|
||||||
IGrnRepository goodsReceiptRepo,
|
IGrnRepository goodsReceiptRepo,
|
||||||
IStockRepository stockRepo,
|
StockMover mover,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
|
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
|
||||||
@@ -89,29 +89,12 @@ public class PurchaseReturnService(
|
|||||||
|
|
||||||
foreach (var line in purchaseReturn.Items)
|
foreach (var line in purchaseReturn.Items)
|
||||||
{
|
{
|
||||||
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
|
var itemName = po.Items.FirstOrDefault(i => i.FK_ItemId == line.FK_ItemId)?.Item?.Name ?? line.FK_ItemId.ToString();
|
||||||
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
|
|
||||||
|
|
||||||
if (balance.Quantity < line.Quantity)
|
await mover.MoveAsync(line.FK_ItemId, itemName,
|
||||||
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
|
warehouseId, LocationType.Warehouse,
|
||||||
|
StockService.ExternalLocationId, LocationType.External,
|
||||||
balance.Quantity -= line.Quantity;
|
line.Quantity, MovementType.PurchaseReturn, purchaseReturn.Id, createdByUserId, request.Reason);
|
||||||
balance.UpdatedAt = DateTime.Now;
|
|
||||||
stockRepo.UpdateBalance(balance);
|
|
||||||
|
|
||||||
await stockRepo.AddMovementAsync(new StockMovement
|
|
||||||
{
|
|
||||||
FK_ItemId = line.FK_ItemId,
|
|
||||||
FromLocationId = warehouseId,
|
|
||||||
FromLocationType = LocationType.Warehouse,
|
|
||||||
ToLocationId = StockService.ExternalLocationId,
|
|
||||||
ToLocationType = LocationType.External,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
Type = MovementType.PurchaseReturn,
|
|
||||||
ReasonCode = request.Reason,
|
|
||||||
ReferenceId = purchaseReturn.Id,
|
|
||||||
CreatedBy = createdByUserId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public interface IRecurringExpenseRepository
|
||||||
|
{
|
||||||
|
Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize);
|
||||||
|
Task<int> CountAsync();
|
||||||
|
Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf);
|
||||||
|
Task AddAsync(Models.RecurringExpenseTemplate entity);
|
||||||
|
void Update(Models.RecurringExpenseTemplate entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public record ToggleRequest(bool Paused);
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/recurring-expenses")]
|
||||||
|
public class RecurringExpenseController(RecurringExpenseService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20) =>
|
||||||
|
Ok(await service.ListAsync(page, pageSize));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> Create(CreateRecurringExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/toggle")]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> Toggle(Guid id, ToggleRequest body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.ToggleAsync(id, body.Paused, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("run-due")]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> RunDue(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.RunDueAsync(ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public record CreateRecurringExpenseRequest(
|
||||||
|
string Name,
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
Guid? VendorId,
|
||||||
|
decimal Amount,
|
||||||
|
string? Payee,
|
||||||
|
string? Description,
|
||||||
|
RecurringFrequency Frequency,
|
||||||
|
int? DayOfMonth,
|
||||||
|
int? DayOfWeek,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
bool AutoPost);
|
||||||
|
|
||||||
|
public record UpdateRecurringExpenseRequest(
|
||||||
|
string Name,
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
Guid? VendorId,
|
||||||
|
decimal Amount,
|
||||||
|
string? Payee,
|
||||||
|
string? Description,
|
||||||
|
RecurringFrequency Frequency,
|
||||||
|
int? DayOfMonth,
|
||||||
|
int? DayOfWeek,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
bool AutoPost);
|
||||||
|
|
||||||
|
public record RecurringExpenseResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
Guid CategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
string? WarehouseName,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? VendorName,
|
||||||
|
decimal Amount,
|
||||||
|
string? Payee,
|
||||||
|
string? Description,
|
||||||
|
RecurringFrequency Frequency,
|
||||||
|
int? DayOfMonth,
|
||||||
|
int? DayOfWeek,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
DateTime NextRunDate,
|
||||||
|
bool AutoPost,
|
||||||
|
bool IsPaused,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PagedRecurringExpensesResponse(List<RecurringExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||||
|
|
||||||
|
public record RunDueResponse(int Generated, List<Guid> ExpenseIds);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public class RecurringExpenseRepository(AppDBContext context) : IRecurringExpenseRepository
|
||||||
|
{
|
||||||
|
private IQueryable<Models.RecurringExpenseTemplate> Base() =>
|
||||||
|
context.RecurringExpenseTemplates
|
||||||
|
.Include(t => t.Category)
|
||||||
|
.Include(t => t.Warehouse)
|
||||||
|
.Include(t => t.Vendor);
|
||||||
|
|
||||||
|
public async Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id) =>
|
||||||
|
await Base().FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize) =>
|
||||||
|
await Base()
|
||||||
|
.Where(t => t.IsActive)
|
||||||
|
.OrderBy(t => t.IsPaused).ThenBy(t => t.NextRunDate)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync() =>
|
||||||
|
await context.RecurringExpenseTemplates.CountAsync(t => t.IsActive);
|
||||||
|
|
||||||
|
public async Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf) =>
|
||||||
|
await Base()
|
||||||
|
.Where(t => t.IsActive && !t.IsPaused && t.NextRunDate <= asOf
|
||||||
|
&& (t.EndDate == null || t.EndDate >= asOf))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.RecurringExpenseTemplate entity) =>
|
||||||
|
await context.RecurringExpenseTemplates.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.RecurringExpenseTemplate entity) =>
|
||||||
|
context.RecurringExpenseTemplates.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public class RecurringExpenseService(
|
||||||
|
AppDBContext context,
|
||||||
|
IRecurringExpenseRepository repo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private static RecurringExpenseResponse Map(Models.RecurringExpenseTemplate t) =>
|
||||||
|
new(t.Id, t.Name,
|
||||||
|
t.FK_CategoryId, t.Category?.Name ?? string.Empty,
|
||||||
|
t.FK_WarehouseId, t.Warehouse?.Name,
|
||||||
|
t.FK_VendorId, t.Vendor?.Name,
|
||||||
|
t.Amount, t.Payee, t.Description,
|
||||||
|
t.Frequency, t.DayOfMonth, t.DayOfWeek,
|
||||||
|
t.StartDate, t.EndDate, t.NextRunDate, t.AutoPost, t.IsPaused, t.IsActive, t.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedRecurringExpensesResponse> ListAsync(int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||||
|
var items = await repo.ListAsync(page, pageSize);
|
||||||
|
var total = await repo.CountAsync();
|
||||||
|
return new PagedRecurringExpensesResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecurringExpenseResponse> CreateAsync(CreateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
|
||||||
|
var category = await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||||
|
?? throw new InvalidOperationException("Category not found");
|
||||||
|
if (category.FK_ParentCategoryId is null)
|
||||||
|
throw new InvalidOperationException("Pick a specific title under a category");
|
||||||
|
|
||||||
|
var entity = new Models.RecurringExpenseTemplate
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
FK_CategoryId = request.CategoryId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
FK_VendorId = request.VendorId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
Payee = request.Payee,
|
||||||
|
Description = request.Description,
|
||||||
|
Frequency = request.Frequency,
|
||||||
|
DayOfMonth = request.DayOfMonth,
|
||||||
|
DayOfWeek = request.DayOfWeek,
|
||||||
|
StartDate = request.StartDate.Date,
|
||||||
|
EndDate = request.EndDate?.Date,
|
||||||
|
NextRunDate = request.StartDate.Date,
|
||||||
|
AutoPost = request.AutoPost
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map((await repo.GetByIdAsync(entity.Id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecurringExpenseResponse?> UpdateAsync(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
|
||||||
|
entity.Name = request.Name.Trim();
|
||||||
|
entity.FK_CategoryId = request.CategoryId;
|
||||||
|
entity.FK_WarehouseId = request.WarehouseId;
|
||||||
|
entity.FK_VendorId = request.VendorId;
|
||||||
|
entity.Amount = request.Amount;
|
||||||
|
entity.Payee = request.Payee;
|
||||||
|
entity.Description = request.Description;
|
||||||
|
entity.Frequency = request.Frequency;
|
||||||
|
entity.DayOfMonth = request.DayOfMonth;
|
||||||
|
entity.DayOfWeek = request.DayOfWeek;
|
||||||
|
entity.StartDate = request.StartDate.Date;
|
||||||
|
entity.EndDate = request.EndDate?.Date;
|
||||||
|
entity.AutoPost = request.AutoPost;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecurringExpenseResponse?> ToggleAsync(Guid id, bool paused, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
entity.IsPaused = paused;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes every active, non-paused template whose NextRunDate ≤ today
|
||||||
|
/// into an Expense row, then advances NextRunDate by one period. Idempotent
|
||||||
|
/// per call — advances only after a successful insert.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RunDueResponse> RunDueAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var due = await repo.ListDueAsync(today);
|
||||||
|
if (due.Count == 0) return new RunDueResponse(0, []);
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var createdIds = new List<Guid>();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var t in due)
|
||||||
|
{
|
||||||
|
var expense = new Models.Expense
|
||||||
|
{
|
||||||
|
FK_CategoryId = t.FK_CategoryId,
|
||||||
|
FK_WarehouseId = t.FK_WarehouseId,
|
||||||
|
FK_VendorId = t.FK_VendorId,
|
||||||
|
Amount = t.Amount,
|
||||||
|
BillDate = t.NextRunDate,
|
||||||
|
PeriodCovered = new DateTime(t.NextRunDate.Year, t.NextRunDate.Month, 1),
|
||||||
|
Payee = t.Payee,
|
||||||
|
Description = t.Description,
|
||||||
|
Status = t.AutoPost ? ExpenseStatus.Posted : ExpenseStatus.Draft,
|
||||||
|
RecordedByUserId = userId,
|
||||||
|
PostedByUserId = t.AutoPost ? userId : null,
|
||||||
|
PostedAt = t.AutoPost ? DateTime.Now : null,
|
||||||
|
FK_RecurringTemplateId = t.Id
|
||||||
|
};
|
||||||
|
await context.Expenses.AddAsync(expense, ct);
|
||||||
|
createdIds.Add(expense.Id);
|
||||||
|
|
||||||
|
t.NextRunDate = AdvanceNext(t.NextRunDate, t.Frequency);
|
||||||
|
repo.Update(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new RunDueResponse(createdIds.Count, createdIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime AdvanceNext(DateTime from, RecurringFrequency freq) => freq switch
|
||||||
|
{
|
||||||
|
RecurringFrequency.Weekly => from.AddDays(7),
|
||||||
|
RecurringFrequency.Monthly => from.AddMonths(1),
|
||||||
|
RecurringFrequency.Quarterly => from.AddMonths(3),
|
||||||
|
RecurringFrequency.Yearly => from.AddYears(1),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown frequency {freq}")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void ValidateSchedule(RecurringFrequency freq, int? dayOfMonth, int? dayOfWeek)
|
||||||
|
{
|
||||||
|
if (freq == RecurringFrequency.Weekly)
|
||||||
|
{
|
||||||
|
if (dayOfWeek is null or < 0 or > 6)
|
||||||
|
throw new InvalidOperationException("DayOfWeek (0-6) is required for Weekly");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (dayOfMonth is null or < 1 or > 31)
|
||||||
|
throw new InvalidOperationException("DayOfMonth (1-31) is required for Monthly/Quarterly/Yearly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
|
|||||||
var result = await service.CreateAsync(id, request, userId.Value, ct);
|
var result = await service.CreateAsync(id, request, userId.Value, ct);
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); }
|
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Features.Category;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
@@ -10,7 +11,8 @@ public class ReturnService(
|
|||||||
IReturnRepository repo,
|
IReturnRepository repo,
|
||||||
IShopPORepository shopPORepo,
|
IShopPORepository shopPORepo,
|
||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStockRepository stockRepo,
|
ICategoryRepository categoryRepo,
|
||||||
|
StockMover mover,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
|
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
|
||||||
@@ -57,6 +59,72 @@ public class ReturnService(
|
|||||||
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
|
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
|
||||||
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
|
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
|
||||||
|
|
||||||
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
|
/// <summary>
|
||||||
throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
|
/// Stock-only: records the return and puts the goods back on the vehicle that delivered them (restoring the
|
||||||
|
/// batches/serials that were sold). Credit/exchange money handling is not applied here, so TotalCreditAmount stays 0.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
|
||||||
|
if (shopPO is null || !shopPO.IsActive)
|
||||||
|
throw new InvalidOperationException("Order not found.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A return must have at least one line item.");
|
||||||
|
|
||||||
|
if (request.Items.GroupBy(i => i.ItemId).Any(g => g.Count() > 1))
|
||||||
|
throw new InvalidOperationException("Each item can appear only once on a return.");
|
||||||
|
|
||||||
|
var saleReturn = new SaleReturn
|
||||||
|
{
|
||||||
|
FK_ShopPOId = shopPOId,
|
||||||
|
Mode = request.Mode,
|
||||||
|
TotalCreditAmount = 0,
|
||||||
|
CreatedByUserId = createdByUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||||
|
|
||||||
|
var shopPOItem = shopPO.Items.FirstOrDefault(i => i.FK_ItemId == line.ItemId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Item {line.ItemId} is not on this order.");
|
||||||
|
|
||||||
|
var item = await itemRepo.GetByIdAsync(line.ItemId)
|
||||||
|
?? throw new InvalidOperationException("Item not found.");
|
||||||
|
|
||||||
|
var category = await categoryRepo.GetByIdAsync(item.FK_CategoryId)
|
||||||
|
?? throw new InvalidOperationException("Category not found.");
|
||||||
|
|
||||||
|
if (!category.AllowCustomerReturn)
|
||||||
|
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
|
||||||
|
|
||||||
|
saleReturn.Items.Add(new SaleReturnItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
ReasonCode = line.ReasonCode,
|
||||||
|
UnitPrice = shopPOItem.UnitPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(saleReturn);
|
||||||
|
|
||||||
|
foreach (var line in saleReturn.Items)
|
||||||
|
{
|
||||||
|
var itemName = shopPO.Items.First(i => i.FK_ItemId == line.FK_ItemId).Item?.Name ?? line.FK_ItemId.ToString();
|
||||||
|
await mover.ReturnToVehicleAsync(line.FK_ItemId, itemName, line.Quantity, shopPOId, createdByUserId, line.ReasonCode.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(saleReturn.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Ride;
|
namespace Distribution.Features.Ride;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/rides")]
|
[Route("api/rides")]
|
||||||
public class RideController(RideService service) : ControllerBase
|
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
@@ -54,7 +55,7 @@ public class RideController(RideService service) : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CloseAsync(id, request, ct);
|
var result = await service.CloseAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -66,7 +67,7 @@ public class RideController(RideService service) : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CancelAsync(id, request, ct);
|
var result = await service.CancelAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
|||||||
@@ -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.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -10,9 +12,14 @@ public class RideService(
|
|||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IDriverProfileRepository driverRepo,
|
IDriverProfileRepository driverRepo,
|
||||||
IDriverComplianceConfigRepository complianceRepo,
|
IDriverComplianceConfigRepository complianceRepo,
|
||||||
IStockRepository stockRepo,
|
IDeliveryTaskRepository taskRepo,
|
||||||
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
|
ILogger<RideService> logger,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string RidesLink = "/operations/rides";
|
||||||
|
|
||||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||||
{
|
{
|
||||||
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||||
@@ -72,11 +79,17 @@ public class RideService(
|
|||||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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);
|
var saved = await rideRepo.GetByIdAsync(rideId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
|
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
@@ -87,20 +100,24 @@ public class RideService(
|
|||||||
if (request.Meterend < ride.Meterstart)
|
if (request.Meterend < ride.Meterstart)
|
||||||
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})");
|
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})");
|
||||||
|
|
||||||
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
|
|
||||||
|
|
||||||
ride.Status = RideStatus.Closed;
|
ride.Status = RideStatus.Closed;
|
||||||
ride.ClosedAt = DateTime.Now;
|
ride.ClosedAt = DateTime.Now;
|
||||||
ride.Meterend = request.Meterend;
|
ride.Meterend = request.Meterend;
|
||||||
|
|
||||||
|
// Whatever wasn't delivered goes back to the warehouse it was loaded from.
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideClosed");
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(ride);
|
return ToResponse(ride);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, CancellationToken ct = default)
|
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
@@ -113,12 +130,54 @@ public class RideService(
|
|||||||
ride.CancelReason = request.Reason;
|
ride.CancelReason = request.Reason;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideCancelled");
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyRideCancelledAsync(ride, request, userId, ct);
|
||||||
|
|
||||||
return ToResponse(ride);
|
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)
|
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||||
{
|
{
|
||||||
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
||||||
@@ -139,13 +198,6 @@ public class RideService(
|
|||||||
throw new InvalidOperationException("Driver medical clearance has expired");
|
throw new InvalidOperationException("Driver medical clearance has expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureVehicleStockClearedAsync(Guid vehicleId)
|
|
||||||
{
|
|
||||||
var balances = await stockRepo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle);
|
|
||||||
if (balances.Any(b => b.Quantity > 0))
|
|
||||||
throw new InvalidOperationException("All vehicle stock must be returned before this ride can be completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RideResponse ToResponse(DeliveryRun r) =>
|
private static RideResponse ToResponse(DeliveryRun r) =>
|
||||||
new(r.Id, r.Status,
|
new(r.Id, r.Status,
|
||||||
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
|
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public interface ISchemeRepository
|
|||||||
|
|
||||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
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> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||||
|
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||||
|
|
||||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ public class SchemeController(SchemeService service) : ControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
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")]
|
[HttpGet("{id:guid}/spend")]
|
||||||
[Authorize("scheme.view")]
|
[Authorize("scheme.view")]
|
||||||
public async Task<IActionResult> GetSpend(Guid id)
|
public async Task<IActionResult> GetSpend(Guid id)
|
||||||
|
|||||||
@@ -94,7 +94,13 @@ public record SchemeEligibilityResponse(
|
|||||||
string? Reason,
|
string? Reason,
|
||||||
decimal? DiscountAmount,
|
decimal? DiscountAmount,
|
||||||
Guid? FreeItemId,
|
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(
|
public record SchemeRedemptionResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -49,9 +49,25 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
|||||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||||
await context.SchemeRedemptions.AddAsync(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) =>
|
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == 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) =>
|
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||||
await context.SchemeBundleComponents
|
await context.SchemeBundleComponents
|
||||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
@@ -14,6 +16,8 @@ public class SchemeService(
|
|||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStoreRepository storeRepo,
|
IStoreRepository storeRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
|
IInvoiceRepository invoiceRepo,
|
||||||
|
PriceListService priceListService,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
@@ -261,12 +265,7 @@ public class SchemeService(
|
|||||||
|
|
||||||
foreach (var scheme in candidates)
|
foreach (var scheme in candidates)
|
||||||
{
|
{
|
||||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||||
continue;
|
|
||||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
|
||||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
result.Add(EvaluateRule(scheme, items));
|
result.Add(EvaluateRule(scheme, items));
|
||||||
@@ -275,6 +274,328 @@ public class SchemeService(
|
|||||||
return result;
|
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>
|
/// <summary>
|
||||||
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
/// 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)`
|
/// 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.");
|
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||||
|
|
||||||
var today = DateTime.Now;
|
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 (false, "Scheme is not currently active (outside its date range).");
|
||||||
|
|
||||||
return (true, null);
|
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)
|
switch (scheme.RuleType)
|
||||||
{
|
{
|
||||||
case SchemeRuleType.BuyXGetY:
|
case SchemeRuleType.BuyXGetY:
|
||||||
@@ -383,6 +708,9 @@ public class SchemeService(
|
|||||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
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 items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||||
var eligibility = EvaluateRule(scheme, items);
|
var eligibility = EvaluateRule(scheme, items);
|
||||||
if (!eligibility.Eligible)
|
if (!eligibility.Eligible)
|
||||||
@@ -411,7 +739,10 @@ public class SchemeService(
|
|||||||
FK_ShopPOId = shopPOId,
|
FK_ShopPOId = shopPOId,
|
||||||
FK_ItemId = freeItem.Id,
|
FK_ItemId = freeItem.Id,
|
||||||
Quantity = (int)eligibility.FreeQty.Value,
|
Quantity = (int)eligibility.FreeQty.Value,
|
||||||
UnitPrice = 0m
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = scheme.Id,
|
||||||
|
SchemeName = scheme.Name
|
||||||
});
|
});
|
||||||
|
|
||||||
redemption.FK_FreeItemId = freeItem.Id;
|
redemption.FK_FreeItemId = freeItem.Id;
|
||||||
@@ -422,7 +753,27 @@ public class SchemeService(
|
|||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
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 repo.AddRedemptionAsync(redemption);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -484,6 +835,8 @@ public class SchemeService(
|
|||||||
|
|
||||||
if (freeItemId is null || freeQty is null or <= 0)
|
if (freeItemId is null || freeQty is null or <= 0)
|
||||||
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
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)
|
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
||||||
throw new InvalidOperationException("Free item not found.");
|
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);
|
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]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
namespace Distribution.Features.ShopPO;
|
namespace Distribution.Features.ShopPO;
|
||||||
|
|
||||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
// 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 ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = 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(
|
public record ShopPOResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -34,4 +39,5 @@ public record ShopPOResponse(
|
|||||||
DateTime? CancelledAt = null,
|
DateTime? CancelledAt = null,
|
||||||
string? CancelReason = null,
|
string? CancelReason = null,
|
||||||
string? CancelledByName = 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.Invoice;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -10,16 +12,47 @@ public class ShopPOService(
|
|||||||
IShopPORepository repo,
|
IShopPORepository repo,
|
||||||
IInvoiceRepository invoiceRepo,
|
IInvoiceRepository invoiceRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
|
SchemeService schemeService,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
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,
|
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||||
p.Items.Where(i => i.IsActive).Select(i =>
|
p.Items.Where(i => i.IsActive).Select(i =>
|
||||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
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,
|
p.IsActive, p.CreatedAt,
|
||||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : 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,
|
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
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 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)
|
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||||
{
|
{
|
||||||
@@ -52,7 +86,14 @@ public class ShopPOService(
|
|||||||
var po = await repo.GetByIdAsync(id);
|
var po = await repo.GetByIdAsync(id);
|
||||||
if (po is null) return null;
|
if (po is null) return null;
|
||||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
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)
|
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)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, request.StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
items.Add(item);
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
total += item.Quantity * item.UnitPrice;
|
||||||
|
|
||||||
items.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||||
|
|
||||||
var po = new Models.ShopPO
|
var po = new Models.ShopPO
|
||||||
{
|
{
|
||||||
FK_StoreId = request.StoreId,
|
FK_StoreId = request.StoreId,
|
||||||
@@ -93,6 +128,7 @@ public class ShopPOService(
|
|||||||
await repo.AddAsync(po);
|
await repo.AddAsync(po);
|
||||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||||
await repo.AddItemsAsync(items);
|
await repo.AddItemsAsync(items);
|
||||||
|
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
@@ -120,27 +156,22 @@ public class ShopPOService(
|
|||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
item.FK_ShopPOId = id;
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
newItems.Add(item);
|
||||||
|
total += item.Quantity * item.UnitPrice;
|
||||||
newItems.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ShopPOId = id,
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||||
|
|
||||||
po.TotalAmount = total;
|
po.TotalAmount = total;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await repo.ReplaceItemsAsync(id, newItems);
|
await repo.ReplaceItemsAsync(id, newItems);
|
||||||
|
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -168,6 +199,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,17 +226,25 @@ public class ShopPOService(
|
|||||||
CreatedBy = userId,
|
CreatedBy = userId,
|
||||||
TotalAmount = po.TotalAmount
|
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);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await invoiceRepo.AddAsync(invoice);
|
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;
|
po.InvoiceId = invoice.Id;
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(id);
|
var saved = await repo.GetByIdAsync(id);
|
||||||
return await ToResponseWithUserAsync(saved!);
|
return await ToResponseWithUserAsync(saved!);
|
||||||
}
|
}
|
||||||
@@ -231,6 +272,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public interface IStockRepository
|
|||||||
// Balance
|
// Balance
|
||||||
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
|
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
|
||||||
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
|
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
|
||||||
|
/// <summary>Positive balances for the item at the location in pick order: batches by earliest expiry, serials oldest first, untracked last.</summary>
|
||||||
|
Task<List<StockBalance>> GetAllocatableBalancesAsync(Guid itemId, Guid locationId);
|
||||||
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
|
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
|
||||||
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
|
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
|
||||||
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
|
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
|
||||||
@@ -19,14 +21,21 @@ public interface IStockRepository
|
|||||||
Task AddMovementAsync(StockMovement movement);
|
Task AddMovementAsync(StockMovement movement);
|
||||||
Task<StockMovement?> GetMovementByIdAsync(Guid id);
|
Task<StockMovement?> GetMovementByIdAsync(Guid id);
|
||||||
void UpdateMovement(StockMovement movement);
|
void UpdateMovement(StockMovement movement);
|
||||||
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
|
Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize);
|
||||||
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
|
Task<int> GetMovementsCountAsync(MovementFilter filter);
|
||||||
|
/// <summary>Opening balance, quantity in/out within [from, toExclusive), closing balance, and the live balance to reconcile against.</summary>
|
||||||
|
Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive);
|
||||||
|
|
||||||
|
Task<List<StockMovement>> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type);
|
||||||
|
/// <summary>The warehouse the item most recently arrived at <paramref name="vehicleId"/> from, or null if it never did.</summary>
|
||||||
|
Task<Guid?> GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId);
|
||||||
|
|
||||||
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
|
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
|
||||||
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
|
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
|
||||||
Task AddItemBatchAsync(ItemBatch batch);
|
Task AddItemBatchAsync(ItemBatch batch);
|
||||||
|
|
||||||
// Serial numbers
|
// Serial numbers
|
||||||
|
Task<ItemSerialNumber?> GetSerialAsync(Guid serialId);
|
||||||
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
|
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
|
||||||
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
|
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
|
||||||
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
|
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
|
||||||
|
|||||||
@@ -58,10 +58,28 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
|||||||
[FromQuery] MovementType? type,
|
[FromQuery] MovementType? type,
|
||||||
[FromQuery] DateTime? from,
|
[FromQuery] DateTime? from,
|
||||||
[FromQuery] DateTime? to,
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] Guid? referenceId,
|
||||||
|
[FromQuery] Guid? batchId,
|
||||||
|
[FromQuery] Guid? serialId,
|
||||||
|
[FromQuery] Guid? createdBy,
|
||||||
|
[FromQuery] string? reasonCode,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20)
|
[FromQuery] int pageSize = 20)
|
||||||
{
|
{
|
||||||
var result = await service.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
|
var filter = new MovementFilter(locationId, itemId, type, from, to, referenceId, batchId, serialId, createdBy, reasonCode);
|
||||||
|
var result = await service.GetMovementsAsync(filter, page, pageSize);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opening / in / out / closing quantity for one item at one location over a date range.</summary>
|
||||||
|
[HttpGet("movements/summary")]
|
||||||
|
public async Task<IActionResult> GetMovementSummary(
|
||||||
|
[FromQuery] Guid itemId,
|
||||||
|
[FromQuery] Guid locationId,
|
||||||
|
[FromQuery] DateTime from,
|
||||||
|
[FromQuery] DateTime to)
|
||||||
|
{
|
||||||
|
var result = await service.GetMovementSummaryAsync(itemId, locationId, from, to);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ public record StockMovementResponse(
|
|||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
Guid CreatedBy,
|
Guid CreatedBy,
|
||||||
string? FromLocationName,
|
string? FromLocationName,
|
||||||
string? ToLocationName);
|
string? ToLocationName,
|
||||||
|
Guid? BatchId = null,
|
||||||
|
string? BatchNumber = null,
|
||||||
|
DateTime? BatchExpiryDate = null,
|
||||||
|
Guid? SerialId = null,
|
||||||
|
string? SerialNumber = null);
|
||||||
|
|
||||||
// Write requests
|
// Write requests
|
||||||
public record TransferRequest(
|
public record TransferRequest(
|
||||||
@@ -82,3 +87,23 @@ public record AdjustmentRequest(
|
|||||||
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded
|
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded
|
||||||
// (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
|
// (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
|
||||||
public record UpdateMovementReasonRequest(string ReasonCode);
|
public record UpdateMovementReasonRequest(string ReasonCode);
|
||||||
|
|
||||||
|
// Movement report filters
|
||||||
|
public record MovementFilter(
|
||||||
|
Guid? LocationId = null,
|
||||||
|
Guid? ItemId = null,
|
||||||
|
MovementType? Type = null,
|
||||||
|
DateTime? From = null,
|
||||||
|
DateTime? To = null,
|
||||||
|
Guid? ReferenceId = null,
|
||||||
|
Guid? BatchId = null,
|
||||||
|
Guid? SerialId = null,
|
||||||
|
Guid? CreatedBy = null,
|
||||||
|
string? ReasonCode = null);
|
||||||
|
|
||||||
|
public record MovementSummary(
|
||||||
|
decimal OpeningQuantity,
|
||||||
|
decimal QuantityIn,
|
||||||
|
decimal QuantityOut,
|
||||||
|
decimal ClosingQuantity,
|
||||||
|
decimal CurrentBalance);
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
using Distribution.Features.SysConfig;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Stock;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a stock movement end to end: picks which batch/serial balances to take from (FEFO / FIFO),
|
||||||
|
/// updates the source and destination balances, keeps serial status/location in step, and writes one
|
||||||
|
/// StockMovement per slice so the ledger always carries the batch/serial that actually moved.
|
||||||
|
/// Tracks changes only — the calling service owns the unit of work.
|
||||||
|
/// </summary>
|
||||||
|
public class StockMover(IStockRepository repo, SysConfigService sysConfig)
|
||||||
|
{
|
||||||
|
private static readonly Guid External = StockService.ExternalLocationId;
|
||||||
|
|
||||||
|
private readonly record struct StockKey(Guid LocationId, Guid? BatchId, Guid? SerialId);
|
||||||
|
|
||||||
|
public async Task<List<StockMovement>> MoveAsync(
|
||||||
|
Guid itemId, string itemName,
|
||||||
|
Guid fromId, LocationType fromType,
|
||||||
|
Guid toId, LocationType toType,
|
||||||
|
decimal quantity, MovementType type,
|
||||||
|
Guid? referenceId, Guid userId, string? reasonCode = null)
|
||||||
|
{
|
||||||
|
if (quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Quantity must be greater than zero.");
|
||||||
|
|
||||||
|
var balances = await repo.GetAllocatableBalancesAsync(itemId, fromId);
|
||||||
|
var available = balances.Sum(b => b.Quantity);
|
||||||
|
if (available < quantity)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Insufficient stock for '{itemName}': available {available}, required {quantity}");
|
||||||
|
|
||||||
|
var created = new List<StockMovement>();
|
||||||
|
var remaining = quantity;
|
||||||
|
foreach (var balance in balances)
|
||||||
|
{
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
var take = Math.Min(balance.Quantity, remaining);
|
||||||
|
|
||||||
|
balance.Quantity -= take;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
repo.UpdateBalance(balance);
|
||||||
|
|
||||||
|
if (toType != LocationType.External)
|
||||||
|
await CreditAsync(itemId, toId, toType, balance.FK_ItemBatchId, balance.FK_ItemSerialNumberId, take);
|
||||||
|
|
||||||
|
if (balance.ItemSerialNumber is not null)
|
||||||
|
await ApplySerialAsync(balance.ItemSerialNumber, balance.Item, toId, toType, type);
|
||||||
|
|
||||||
|
var movement = new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = itemId,
|
||||||
|
FromLocationId = fromId,
|
||||||
|
FromLocationType = fromType,
|
||||||
|
ToLocationId = toId,
|
||||||
|
ToLocationType = toType,
|
||||||
|
FK_ItemBatchId = balance.FK_ItemBatchId,
|
||||||
|
FK_ItemSerialNumberId = balance.FK_ItemSerialNumberId,
|
||||||
|
Quantity = take,
|
||||||
|
Type = type,
|
||||||
|
ReasonCode = reasonCode,
|
||||||
|
ReferenceId = referenceId,
|
||||||
|
CreatedBy = userId
|
||||||
|
};
|
||||||
|
await repo.AddMovementAsync(movement);
|
||||||
|
created.Add(movement);
|
||||||
|
|
||||||
|
remaining -= take;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts returned goods back on the vehicle that delivered them, restoring the same batches/serials that
|
||||||
|
/// were sold on that ShopPO. Quantity is capped at what was delivered and not yet returned.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<StockMovement>> ReturnToVehicleAsync(
|
||||||
|
Guid itemId, string itemName, decimal quantity, Guid shopPoId, Guid userId, string? reasonCode = null)
|
||||||
|
{
|
||||||
|
if (quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Quantity must be greater than zero.");
|
||||||
|
|
||||||
|
var sold = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.Sale);
|
||||||
|
if (sold.Count == 0)
|
||||||
|
throw new InvalidOperationException($"'{itemName}' was not delivered on this order, so it cannot be returned.");
|
||||||
|
|
||||||
|
var returned = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.CustomerReturn);
|
||||||
|
|
||||||
|
var outstanding = new Dictionary<StockKey, decimal>();
|
||||||
|
foreach (var m in sold)
|
||||||
|
{
|
||||||
|
var key = new StockKey(m.FromLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
|
||||||
|
outstanding[key] = outstanding.GetValueOrDefault(key) + m.Quantity;
|
||||||
|
}
|
||||||
|
foreach (var m in returned)
|
||||||
|
{
|
||||||
|
var key = new StockKey(m.ToLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
|
||||||
|
outstanding[key] = outstanding.GetValueOrDefault(key) - m.Quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
var returnable = outstanding.Where(kv => kv.Value > 0).Sum(kv => kv.Value);
|
||||||
|
if (quantity > returnable)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Return quantity for '{itemName}' ({quantity}) exceeds the delivered-but-not-yet-returned quantity ({returnable}).");
|
||||||
|
|
||||||
|
var created = new List<StockMovement>();
|
||||||
|
var remaining = quantity;
|
||||||
|
foreach (var (key, qty) in outstanding.Where(kv => kv.Value > 0))
|
||||||
|
{
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
var take = Math.Min(qty, remaining);
|
||||||
|
|
||||||
|
await CreditAsync(itemId, key.LocationId, LocationType.Vehicle, key.BatchId, key.SerialId, take);
|
||||||
|
|
||||||
|
if (key.SerialId is Guid serialId)
|
||||||
|
{
|
||||||
|
var serial = await repo.GetSerialAsync(serialId);
|
||||||
|
if (serial is not null)
|
||||||
|
await ApplySerialAsync(serial, null, key.LocationId, LocationType.Vehicle, MovementType.CustomerReturn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var movement = new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = itemId,
|
||||||
|
FromLocationId = External,
|
||||||
|
FromLocationType = LocationType.External,
|
||||||
|
ToLocationId = key.LocationId,
|
||||||
|
ToLocationType = LocationType.Vehicle,
|
||||||
|
FK_ItemBatchId = key.BatchId,
|
||||||
|
FK_ItemSerialNumberId = key.SerialId,
|
||||||
|
Quantity = take,
|
||||||
|
Type = MovementType.CustomerReturn,
|
||||||
|
ReasonCode = reasonCode,
|
||||||
|
ReferenceId = shopPoId,
|
||||||
|
CreatedBy = userId
|
||||||
|
};
|
||||||
|
await repo.AddMovementAsync(movement);
|
||||||
|
created.Add(movement);
|
||||||
|
|
||||||
|
remaining -= take;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends everything still on the vehicle back to the warehouse it was loaded from.
|
||||||
|
/// </summary>
|
||||||
|
public async Task UnloadVehicleAsync(Guid vehicleId, Guid referenceId, Guid userId, string reasonCode)
|
||||||
|
{
|
||||||
|
var onVehicle = (await repo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle))
|
||||||
|
.Where(b => b.Quantity > 0)
|
||||||
|
.GroupBy(b => b.FK_ItemId);
|
||||||
|
|
||||||
|
foreach (var group in onVehicle)
|
||||||
|
{
|
||||||
|
var item = group.First().Item;
|
||||||
|
var warehouseId = await repo.GetLastWarehouseSourceAsync(vehicleId, group.Key)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Cannot unload '{item.Name}': no source warehouse found for the stock on this vehicle.");
|
||||||
|
|
||||||
|
await MoveAsync(group.Key, item.Name, vehicleId, LocationType.Vehicle,
|
||||||
|
warehouseId, LocationType.Warehouse, group.Sum(b => b.Quantity),
|
||||||
|
MovementType.Transfer, referenceId, userId, reasonCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreditAsync(Guid itemId, Guid locationId, LocationType locationType,
|
||||||
|
Guid? batchId, Guid? serialId, decimal quantity)
|
||||||
|
{
|
||||||
|
var balance = await repo.GetBalanceAsync(itemId, locationId, batchId, serialId);
|
||||||
|
if (balance is null)
|
||||||
|
{
|
||||||
|
await repo.AddBalanceAsync(new StockBalance
|
||||||
|
{
|
||||||
|
FK_ItemId = itemId,
|
||||||
|
LocationId = locationId,
|
||||||
|
LocationType = locationType,
|
||||||
|
FK_ItemBatchId = batchId,
|
||||||
|
FK_ItemSerialNumberId = serialId,
|
||||||
|
Quantity = quantity
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
balance.Quantity += quantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
repo.UpdateBalance(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplySerialAsync(ItemSerialNumber serial, Models.Item? item, Guid toId, LocationType toType, MovementType type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case MovementType.Sale:
|
||||||
|
serial.Status = SerialStatus.Sold;
|
||||||
|
serial.CurrentLocationId = null;
|
||||||
|
serial.CurrentLocationType = null;
|
||||||
|
// Warranty clock starts at sale, not receipt.
|
||||||
|
if (item is { HasWarranty: true } && await sysConfig.GetWarrantyTrackingEnabledAsync())
|
||||||
|
{
|
||||||
|
var days = serial.WarrantyPeriodDaysOverride ?? await sysConfig.GetDefaultWarrantyPeriodDaysAsync();
|
||||||
|
serial.WarrantyExpiresAt = DateTime.Now.AddDays(days);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MovementType.Damage:
|
||||||
|
serial.Status = SerialStatus.Damaged;
|
||||||
|
serial.CurrentLocationId = null;
|
||||||
|
serial.CurrentLocationType = null;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MovementType.CustomerReturn:
|
||||||
|
serial.Status = SerialStatus.Returned;
|
||||||
|
serial.CurrentLocationId = toId;
|
||||||
|
serial.CurrentLocationType = toType;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (toType == LocationType.External)
|
||||||
|
{
|
||||||
|
serial.CurrentLocationId = null;
|
||||||
|
serial.CurrentLocationType = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (type == MovementType.Load) serial.Status = SerialStatus.InStock;
|
||||||
|
serial.CurrentLocationId = toId;
|
||||||
|
serial.CurrentLocationType = toType;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,16 +7,39 @@ namespace Distribution.Features.Stock;
|
|||||||
public class StockRepository(AppDBContext db) : IStockRepository
|
public class StockRepository(AppDBContext db) : IStockRepository
|
||||||
{
|
{
|
||||||
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
|
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
|
||||||
db.StockBalances
|
GetBalanceAsync(itemId, locationId, null, null);
|
||||||
.Include(b => b.Item)
|
|
||||||
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
|
|
||||||
&& b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null);
|
|
||||||
|
|
||||||
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) =>
|
// Looks at rows added earlier in the same unit of work first — they aren't in the database yet,
|
||||||
db.StockBalances
|
// so a second movement for the same (item, location, batch, serial) would otherwise insert a duplicate.
|
||||||
|
public async Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId)
|
||||||
|
{
|
||||||
|
var pending = db.StockBalances.Local.FirstOrDefault(b => b.FK_ItemId == itemId && b.LocationId == locationId
|
||||||
|
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
|
||||||
|
if (pending is not null) return pending;
|
||||||
|
|
||||||
|
return await db.StockBalances
|
||||||
.Include(b => b.Item)
|
.Include(b => b.Item)
|
||||||
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
|
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
|
||||||
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
|
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<StockBalance>> GetAllocatableBalancesAsync(Guid itemId, Guid locationId)
|
||||||
|
{
|
||||||
|
var balances = await db.StockBalances
|
||||||
|
.Include(b => b.Item)
|
||||||
|
.Include(b => b.ItemBatch)
|
||||||
|
.Include(b => b.ItemSerialNumber)
|
||||||
|
.Where(b => b.FK_ItemId == itemId && b.LocationId == locationId && b.Quantity > 0)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Tracked stock first: earliest expiry (FEFO), then oldest received serial (FIFO); untracked rows last.
|
||||||
|
return balances
|
||||||
|
.OrderBy(b => b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null ? 1 : 0)
|
||||||
|
.ThenBy(b => b.ItemBatch?.ExpiryDate ?? DateTime.MaxValue)
|
||||||
|
.ThenBy(b => b.ItemSerialNumber?.ReceivedAt ?? DateTime.MaxValue)
|
||||||
|
.ThenBy(b => b.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
|
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
|
||||||
db.StockBalances
|
db.StockBalances
|
||||||
@@ -69,41 +92,88 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
|||||||
await db.StockMovements.AddAsync(movement);
|
await db.StockMovements.AddAsync(movement);
|
||||||
|
|
||||||
public Task<StockMovement?> GetMovementByIdAsync(Guid id) =>
|
public Task<StockMovement?> GetMovementByIdAsync(Guid id) =>
|
||||||
db.StockMovements.Include(m => m.Item).FirstOrDefaultAsync(m => m.Id == id);
|
db.StockMovements.Include(m => m.Item).Include(m => m.ItemBatch).Include(m => m.ItemSerialNumber).FirstOrDefaultAsync(m => m.Id == id);
|
||||||
|
|
||||||
public void UpdateMovement(StockMovement movement) =>
|
public void UpdateMovement(StockMovement movement) =>
|
||||||
db.StockMovements.Update(movement);
|
db.StockMovements.Update(movement);
|
||||||
|
|
||||||
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
|
private IQueryable<StockMovement> FilterMovements(MovementFilter f)
|
||||||
{
|
|
||||||
var query = db.StockMovements.Include(m => m.Item).AsQueryable();
|
|
||||||
if (locationId.HasValue)
|
|
||||||
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
|
|
||||||
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
|
|
||||||
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
|
|
||||||
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
|
|
||||||
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
|
|
||||||
return query.OrderByDescending(m => m.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to)
|
|
||||||
{
|
{
|
||||||
var query = db.StockMovements.AsQueryable();
|
var query = db.StockMovements.AsQueryable();
|
||||||
if (locationId.HasValue)
|
if (f.LocationId.HasValue)
|
||||||
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
|
query = query.Where(m => m.FromLocationId == f.LocationId.Value || m.ToLocationId == f.LocationId.Value);
|
||||||
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
|
if (f.ItemId.HasValue) query = query.Where(m => m.FK_ItemId == f.ItemId.Value);
|
||||||
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
|
if (f.Type.HasValue) query = query.Where(m => m.Type == f.Type.Value);
|
||||||
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
|
if (f.ReferenceId.HasValue) query = query.Where(m => m.ReferenceId == f.ReferenceId.Value);
|
||||||
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
|
if (f.BatchId.HasValue) query = query.Where(m => m.FK_ItemBatchId == f.BatchId.Value);
|
||||||
return query.CountAsync();
|
if (f.SerialId.HasValue) query = query.Where(m => m.FK_ItemSerialNumberId == f.SerialId.Value);
|
||||||
|
if (f.CreatedBy.HasValue) query = query.Where(m => m.CreatedBy == f.CreatedBy.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(f.ReasonCode)) query = query.Where(m => m.ReasonCode == f.ReasonCode);
|
||||||
|
if (f.From.HasValue) query = query.Where(m => m.CreatedAt >= f.From.Value);
|
||||||
|
if (f.To.HasValue)
|
||||||
|
{
|
||||||
|
// A bare date (midnight) means "through the end of that day".
|
||||||
|
var to = f.To.Value;
|
||||||
|
var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
|
||||||
|
query = query.Where(m => m.CreatedAt < toExclusive);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize) =>
|
||||||
|
FilterMovements(filter)
|
||||||
|
.Include(m => m.Item)
|
||||||
|
.Include(m => m.ItemBatch)
|
||||||
|
.Include(m => m.ItemSerialNumber)
|
||||||
|
.OrderByDescending(m => m.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<int> GetMovementsCountAsync(MovementFilter filter) =>
|
||||||
|
FilterMovements(filter).CountAsync();
|
||||||
|
|
||||||
|
public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive)
|
||||||
|
{
|
||||||
|
var rows = db.StockMovements.AsNoTracking()
|
||||||
|
.Where(m => m.FK_ItemId == itemId && (m.FromLocationId == locationId || m.ToLocationId == locationId));
|
||||||
|
|
||||||
|
var before = await rows.Where(m => m.CreatedAt < from)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => g.Sum(m => m.ToLocationId == locationId ? m.Quantity : 0)
|
||||||
|
- g.Sum(m => m.FromLocationId == locationId ? m.Quantity : 0))
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
var inRange = rows.Where(m => m.CreatedAt >= from && m.CreatedAt < toExclusive);
|
||||||
|
var received = await inRange.Where(m => m.ToLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0;
|
||||||
|
var issued = await inRange.Where(m => m.FromLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0;
|
||||||
|
var current = await db.StockBalances.AsNoTracking()
|
||||||
|
.Where(b => b.FK_ItemId == itemId && b.LocationId == locationId)
|
||||||
|
.SumAsync(b => (decimal?)b.Quantity) ?? 0;
|
||||||
|
|
||||||
|
return new MovementSummary(before, received, issued, before + received - issued, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<StockMovement>> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type) =>
|
||||||
|
db.StockMovements.AsNoTracking()
|
||||||
|
.Where(m => m.ReferenceId == referenceId && m.FK_ItemId == itemId && m.Type == type)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<Guid?> GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId) =>
|
||||||
|
db.StockMovements.AsNoTracking()
|
||||||
|
.Where(m => m.ToLocationId == vehicleId && m.FK_ItemId == itemId
|
||||||
|
&& m.FromLocationType == LocationType.Warehouse)
|
||||||
|
.OrderByDescending(m => m.CreatedAt)
|
||||||
|
.Select(m => m.FromLocationId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
|
public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
|
||||||
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
|
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
|
||||||
|
|
||||||
public async Task AddItemBatchAsync(ItemBatch batch) =>
|
public async Task AddItemBatchAsync(ItemBatch batch) =>
|
||||||
await db.ItemBatches.AddAsync(batch);
|
await db.ItemBatches.AddAsync(batch);
|
||||||
|
|
||||||
|
public Task<ItemSerialNumber?> GetSerialAsync(Guid serialId) =>
|
||||||
|
db.ItemSerialNumbers.FirstOrDefaultAsync(s => s.Id == serialId);
|
||||||
|
|
||||||
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
|
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
|
||||||
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
|
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class StockService(
|
|||||||
ICategoryRepository categoryRepo,
|
ICategoryRepository categoryRepo,
|
||||||
IWarehouseRepository warehouseRepo,
|
IWarehouseRepository warehouseRepo,
|
||||||
IVehicleRepository vehicleRepo,
|
IVehicleRepository vehicleRepo,
|
||||||
|
StockMover mover,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
|
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
|
||||||
@@ -37,7 +38,9 @@ public class StockService(
|
|||||||
private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
|
private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
|
||||||
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
|
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
|
||||||
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode,
|
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode,
|
||||||
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName);
|
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName,
|
||||||
|
m.FK_ItemBatchId, m.ItemBatch?.BatchNumber, m.ItemBatch?.ExpiryDate,
|
||||||
|
m.FK_ItemSerialNumberId, m.ItemSerialNumber?.SerialNumber);
|
||||||
|
|
||||||
private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
|
private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
|
||||||
{
|
{
|
||||||
@@ -112,11 +115,16 @@ public class StockService(
|
|||||||
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
|
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(
|
public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to)
|
||||||
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
|
|
||||||
{
|
{
|
||||||
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
|
var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
|
||||||
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to);
|
return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(MovementFilter filter, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var items = await repo.GetMovementsAsync(filter, page, pageSize);
|
||||||
|
var total = await repo.GetMovementsCountAsync(filter);
|
||||||
|
|
||||||
var locations = new List<(Guid, LocationType)>();
|
var locations = new List<(Guid, LocationType)>();
|
||||||
foreach (var m in items)
|
foreach (var m in items)
|
||||||
@@ -137,60 +145,42 @@ public class StockService(
|
|||||||
|
|
||||||
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
|
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity);
|
var item = await itemRepo.GetByIdAsync(request.ItemId)
|
||||||
|
?? throw new InvalidOperationException("Item not found.");
|
||||||
var movement = new StockMovement
|
|
||||||
{
|
|
||||||
FK_ItemId = request.ItemId,
|
|
||||||
FromLocationId = request.FromLocationId,
|
|
||||||
FromLocationType = request.FromLocationType,
|
|
||||||
ToLocationId = request.ToLocationId,
|
|
||||||
ToLocationType = request.ToLocationType,
|
|
||||||
Quantity = request.Quantity,
|
|
||||||
Type = MovementType.Transfer,
|
|
||||||
ReferenceId = request.ReferenceId,
|
|
||||||
CreatedBy = userId
|
|
||||||
};
|
|
||||||
|
|
||||||
|
List<StockMovement> movements;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity);
|
movements = await mover.MoveAsync(request.ItemId, item.Name,
|
||||||
await repo.AddMovementAsync(movement);
|
request.FromLocationId, request.FromLocationType,
|
||||||
|
request.ToLocationId, request.ToLocationType,
|
||||||
|
request.Quantity, MovementType.Transfer, request.ReferenceId, userId);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return await MapMovementAsync(movement);
|
return await MapMovementsAsync(movements, item, request.Quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
|
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity);
|
var item = await itemRepo.GetByIdAsync(request.ItemId)
|
||||||
|
?? throw new InvalidOperationException("Item not found.");
|
||||||
var movement = new StockMovement
|
|
||||||
{
|
|
||||||
FK_ItemId = request.ItemId,
|
|
||||||
FromLocationId = request.LocationId,
|
|
||||||
FromLocationType = request.LocationType,
|
|
||||||
ToLocationId = ExternalLocationId,
|
|
||||||
ToLocationType = LocationType.External,
|
|
||||||
Quantity = request.Quantity,
|
|
||||||
Type = MovementType.Damage,
|
|
||||||
ReasonCode = request.ReasonCode,
|
|
||||||
CreatedBy = userId
|
|
||||||
};
|
|
||||||
|
|
||||||
|
List<StockMovement> movements;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity);
|
movements = await mover.MoveAsync(request.ItemId, item.Name,
|
||||||
await repo.AddMovementAsync(movement);
|
request.LocationId, request.LocationType,
|
||||||
|
ExternalLocationId, LocationType.External,
|
||||||
|
request.Quantity, MovementType.Damage, null, userId, request.ReasonCode);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return await MapMovementAsync(movement);
|
return await MapMovementsAsync(movements, item, request.Quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
|
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
|
||||||
@@ -204,75 +194,89 @@ public class StockService(
|
|||||||
if (!category.AllowCustomerReturn)
|
if (!category.AllowCustomerReturn)
|
||||||
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
|
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
|
||||||
|
|
||||||
var movement = new StockMovement
|
var tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate;
|
||||||
{
|
if (tracked && request.ReferenceId is null)
|
||||||
FK_ItemId = request.ItemId,
|
throw new InvalidOperationException(
|
||||||
FromLocationId = ExternalLocationId,
|
$"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on.");
|
||||||
FromLocationType = LocationType.External,
|
|
||||||
ToLocationId = request.VehicleId,
|
|
||||||
ToLocationType = LocationType.Vehicle,
|
|
||||||
Quantity = request.Quantity,
|
|
||||||
Type = MovementType.CustomerReturn,
|
|
||||||
ReferenceId = request.ReferenceId,
|
|
||||||
CreatedBy = userId
|
|
||||||
};
|
|
||||||
|
|
||||||
|
List<StockMovement> movements;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
|
if (tracked)
|
||||||
await repo.AddMovementAsync(movement);
|
{
|
||||||
|
movements = await mover.ReturnToVehicleAsync(request.ItemId, item.Name, request.Quantity,
|
||||||
|
request.ReferenceId!.Value, userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var movement = new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = request.ItemId,
|
||||||
|
FromLocationId = ExternalLocationId,
|
||||||
|
FromLocationType = LocationType.External,
|
||||||
|
ToLocationId = request.VehicleId,
|
||||||
|
ToLocationType = LocationType.Vehicle,
|
||||||
|
Quantity = request.Quantity,
|
||||||
|
Type = MovementType.CustomerReturn,
|
||||||
|
ReferenceId = request.ReferenceId,
|
||||||
|
CreatedBy = userId
|
||||||
|
};
|
||||||
|
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
|
||||||
|
await repo.AddMovementAsync(movement);
|
||||||
|
movements = [movement];
|
||||||
|
}
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return await MapMovementAsync(movement);
|
return await MapMovementsAsync(movements, item, request.Quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
|
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId);
|
var item = await itemRepo.GetByIdAsync(request.ItemId)
|
||||||
if (balance is not null && balance.Quantity + request.Quantity < 0)
|
?? throw new InvalidOperationException("Item not found.");
|
||||||
throw new InvalidOperationException("Adjustment would result in negative stock.");
|
|
||||||
|
|
||||||
var movement = new StockMovement
|
|
||||||
{
|
|
||||||
FK_ItemId = request.ItemId,
|
|
||||||
FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId,
|
|
||||||
FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External,
|
|
||||||
ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId,
|
|
||||||
ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External,
|
|
||||||
Quantity = Math.Abs(request.Quantity),
|
|
||||||
Type = MovementType.Adjustment,
|
|
||||||
ReasonCode = request.ReasonCode,
|
|
||||||
CreatedBy = userId
|
|
||||||
};
|
|
||||||
|
|
||||||
|
List<StockMovement> movements;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (balance is null)
|
if (request.Quantity < 0)
|
||||||
{
|
{
|
||||||
await repo.AddBalanceAsync(new StockBalance
|
movements = await mover.MoveAsync(request.ItemId, item.Name,
|
||||||
{
|
request.LocationId, request.LocationType,
|
||||||
FK_ItemId = request.ItemId,
|
ExternalLocationId, LocationType.External,
|
||||||
LocationId = request.LocationId,
|
-request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode);
|
||||||
LocationType = request.LocationType,
|
|
||||||
Quantity = request.Quantity
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
balance.Quantity += request.Quantity;
|
// A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN.
|
||||||
balance.UpdatedAt = DateTime.Now;
|
if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate)
|
||||||
repo.UpdateBalance(balance);
|
throw new InvalidOperationException(
|
||||||
|
$"'{item.Name}' is batch/serial tracked — increase its stock by receiving a GRN, not by adjustment.");
|
||||||
|
|
||||||
|
var movement = new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = request.ItemId,
|
||||||
|
FromLocationId = ExternalLocationId,
|
||||||
|
FromLocationType = LocationType.External,
|
||||||
|
ToLocationId = request.LocationId,
|
||||||
|
ToLocationType = request.LocationType,
|
||||||
|
Quantity = request.Quantity,
|
||||||
|
Type = MovementType.Adjustment,
|
||||||
|
ReasonCode = request.ReasonCode,
|
||||||
|
CreatedBy = userId
|
||||||
|
};
|
||||||
|
await CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity);
|
||||||
|
await repo.AddMovementAsync(movement);
|
||||||
|
movements = [movement];
|
||||||
}
|
}
|
||||||
await repo.AddMovementAsync(movement);
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return await MapMovementAsync(movement);
|
return await MapMovementsAsync(movements, item, Math.Abs(request.Quantity));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type.
|
// Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type.
|
||||||
@@ -282,28 +286,29 @@ public class StockService(
|
|||||||
?? throw new InvalidOperationException("Movement not found.");
|
?? throw new InvalidOperationException("Movement not found.");
|
||||||
|
|
||||||
movement.ReasonCode = request.ReasonCode;
|
movement.ReasonCode = request.ReasonCode;
|
||||||
repo.UpdateMovement(movement);
|
|
||||||
await uow.SaveChangesAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.UpdateMovement(movement);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return await MapMovementAsync(movement);
|
return await MapMovementAsync(movement);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity)
|
/// <summary>
|
||||||
|
/// One logical operation can produce several movements (one per batch/serial slice). The caller gets a
|
||||||
|
/// single response for the operation: the first slice's locations, with the total quantity moved.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<StockMovementResponse> MapMovementsAsync(List<StockMovement> movements, Models.Item item, decimal totalQuantity)
|
||||||
{
|
{
|
||||||
var balance = await repo.GetBalanceAsync(itemId, locationId);
|
var first = movements[0];
|
||||||
if (balance is null || balance.Quantity < quantity)
|
first.Item ??= item;
|
||||||
throw new InvalidOperationException("Insufficient stock at the specified location.");
|
return (await MapMovementAsync(first)) with { Quantity = totalQuantity };
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DebitBalanceAsync(Guid itemId, Guid locationId, decimal quantity)
|
|
||||||
{
|
|
||||||
var balance = await repo.GetBalanceAsync(itemId, locationId)
|
|
||||||
?? throw new InvalidOperationException("No stock balance found.");
|
|
||||||
balance.Quantity -= quantity;
|
|
||||||
balance.UpdatedAt = DateTime.Now;
|
|
||||||
repo.UpdateBalance(balance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity)
|
private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity)
|
||||||
@@ -326,10 +331,4 @@ public class StockService(
|
|||||||
repo.UpdateBalance(balance);
|
repo.UpdateBalance(balance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyTransferAsync(Guid itemId, Guid fromLocationId, Guid toLocationId, LocationType toLocationType, decimal quantity)
|
|
||||||
{
|
|
||||||
await DebitBalanceAsync(itemId, fromLocationId, quantity);
|
|
||||||
await CreditBalanceAsync(itemId, toLocationId, toLocationType, quantity);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Distribution.Features.StockReport;
|
||||||
|
|
||||||
|
public record LedgerAggregate(
|
||||||
|
Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom,
|
||||||
|
decimal? CostPrice, decimal? ReorderLevel,
|
||||||
|
decimal Opening, decimal In, decimal Out, decimal Current, decimal WarehouseQty);
|
||||||
|
|
||||||
|
public record ExpiryAggregate(
|
||||||
|
Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate,
|
||||||
|
decimal Quantity, decimal? CostPrice, Guid LocationId, string LocationType, string LocationName);
|
||||||
|
|
||||||
|
public record OverviewAggregate(
|
||||||
|
int ActiveItems, int InStockItems, int LowStockItems, decimal TotalQty, decimal TotalValue,
|
||||||
|
int ExpiringSoonBatches, int ExpiredBatches);
|
||||||
|
|
||||||
|
public interface IStockReportRepository
|
||||||
|
{
|
||||||
|
Task<bool> IsWarehouseAsync(Guid locationId);
|
||||||
|
Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays);
|
||||||
|
Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(StockReportFilter f, bool includeWarehouseLow, int page, int pageSize);
|
||||||
|
Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(Guid? locationId, string? search, DateTime cutoff, int page, int pageSize);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.StockReport;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports/stock")]
|
||||||
|
public class StockReportController(StockReportService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("overview")]
|
||||||
|
public async Task<IActionResult> Overview([FromQuery] Guid? locationId, [FromQuery] int expiryDays = 30)
|
||||||
|
{
|
||||||
|
var error = StockReportService.ValidateExpiry(expiryDays, 1, 1);
|
||||||
|
return error is null ? Ok(await service.GetOverviewAsync(locationId, expiryDays)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("ledger")]
|
||||||
|
public async Task<IActionResult> Ledger(
|
||||||
|
[FromQuery] Guid? locationId, [FromQuery] Guid? categoryId, [FromQuery] Guid? brandId,
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] string? status = null,
|
||||||
|
[FromQuery] bool activeOnly = true, [FromQuery] string? search = null,
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||||
|
{
|
||||||
|
var end = (to ?? DateTime.Now).Date;
|
||||||
|
var start = (from ?? end.AddDays(-30)).Date;
|
||||||
|
var f = new StockReportFilter(locationId, categoryId, brandId, search, start, end, status, activeOnly);
|
||||||
|
var error = StockReportService.Validate(f, page, pageSize);
|
||||||
|
return error is null ? Ok(await service.GetLedgerAsync(f, page, pageSize)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("expiry")]
|
||||||
|
public async Task<IActionResult> Expiry(
|
||||||
|
[FromQuery] Guid? locationId, [FromQuery] string? search = null, [FromQuery] int days = 30,
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||||
|
{
|
||||||
|
var error = StockReportService.ValidateExpiry(days, page, pageSize);
|
||||||
|
return error is null
|
||||||
|
? Ok(await service.GetExpiryAsync(locationId, search, days, page, pageSize))
|
||||||
|
: BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Distribution.Features.StockReport;
|
||||||
|
|
||||||
|
public static class StockStatus
|
||||||
|
{
|
||||||
|
public const string Ok = "OK";
|
||||||
|
public const string Low = "Low";
|
||||||
|
public const string Out = "Out";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StockReportFilter(
|
||||||
|
Guid? LocationId, Guid? CategoryId, Guid? BrandId, string? Search,
|
||||||
|
DateTime From, DateTime To, string? Status, bool ActiveOnly);
|
||||||
|
|
||||||
|
public record StockOverview(
|
||||||
|
int TotalSkus, decimal TotalQty, decimal TotalValue,
|
||||||
|
int LowStockCount, int OutOfStockCount, int ExpiringSoonCount, int ExpiredCount);
|
||||||
|
|
||||||
|
public record StockLedgerRow(
|
||||||
|
Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom,
|
||||||
|
decimal OpeningQty, decimal QtyIn, decimal QtyOut, decimal ClosingQty, decimal CurrentQty,
|
||||||
|
decimal? CostPrice, decimal StockValue, decimal? ReorderLevel, string Status);
|
||||||
|
|
||||||
|
public record StockExpiryRow(
|
||||||
|
Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate, int DaysLeft,
|
||||||
|
decimal Quantity, decimal? CostPrice, decimal Value, Guid LocationId, string LocationType, string LocationName);
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.StockReport;
|
||||||
|
|
||||||
|
public class StockReportRepository(AppDBContext db) : IStockReportRepository
|
||||||
|
{
|
||||||
|
// One location when picked, otherwise every internal location (warehouses + vehicles; External is not tracked).
|
||||||
|
private IQueryable<StockBalance> ScopedBalances(Guid? locationId)
|
||||||
|
{
|
||||||
|
var q = db.StockBalances.AsNoTracking().AsQueryable();
|
||||||
|
return locationId.HasValue
|
||||||
|
? q.Where(b => b.LocationId == locationId.Value)
|
||||||
|
: q.Where(b => b.LocationType == LocationType.Warehouse || b.LocationType == LocationType.Vehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> IsWarehouseAsync(Guid locationId) =>
|
||||||
|
db.Warehouses.AsNoTracking().AnyAsync(w => w.Id == locationId);
|
||||||
|
|
||||||
|
public async Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays)
|
||||||
|
{
|
||||||
|
var balances = ScopedBalances(locationId);
|
||||||
|
var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse);
|
||||||
|
|
||||||
|
var perItem = db.Items.AsNoTracking().Where(i => i.IsActive).Select(i => new
|
||||||
|
{
|
||||||
|
i.ReorderLevel,
|
||||||
|
i.CostPrice,
|
||||||
|
Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||||
|
Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||||
|
});
|
||||||
|
|
||||||
|
var activeItems = await perItem.CountAsync();
|
||||||
|
var inStockItems = await perItem.CountAsync(x => x.Qty > 0);
|
||||||
|
var lowItems = includeWarehouseLow
|
||||||
|
? await perItem.CountAsync(x => x.Qty > 0 && x.ReorderLevel != null && x.Wh <= x.ReorderLevel)
|
||||||
|
: 0;
|
||||||
|
var totalQty = await perItem.SumAsync(x => (decimal?)x.Qty) ?? 0m;
|
||||||
|
var totalValue = await perItem.SumAsync(x => (decimal?)(x.Qty * (x.CostPrice ?? 0m))) ?? 0m;
|
||||||
|
|
||||||
|
var soonEnd = today.AddDays(expiryDays + 1);
|
||||||
|
var batchBalances = balances.Where(b => b.FK_ItemBatchId != null && b.Quantity > 0
|
||||||
|
&& b.ItemBatch!.ExpiryDate != null);
|
||||||
|
var expired = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate < today)
|
||||||
|
.Select(b => b.FK_ItemBatchId).Distinct().CountAsync();
|
||||||
|
var soon = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate >= today && b.ItemBatch.ExpiryDate < soonEnd)
|
||||||
|
.Select(b => b.FK_ItemBatchId).Distinct().CountAsync();
|
||||||
|
|
||||||
|
return new OverviewAggregate(activeItems, inStockItems, lowItems, totalQty, totalValue, soon, expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(
|
||||||
|
StockReportFilter f, bool includeWarehouseLow, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var balances = ScopedBalances(f.LocationId);
|
||||||
|
var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse);
|
||||||
|
|
||||||
|
var items = db.Items.AsNoTracking().AsQueryable();
|
||||||
|
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
|
||||||
|
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
|
||||||
|
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||||
|
{
|
||||||
|
var s = f.Search.Trim();
|
||||||
|
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
var withQty = items.Select(i => new
|
||||||
|
{
|
||||||
|
Item = i,
|
||||||
|
Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||||
|
Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (f.Status == "out")
|
||||||
|
withQty = withQty.Where(x => x.Qty <= 0);
|
||||||
|
else if (f.Status == "low")
|
||||||
|
withQty = includeWarehouseLow
|
||||||
|
? withQty.Where(x => x.Qty > 0 && x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel)
|
||||||
|
: withQty.Where(x => false);
|
||||||
|
|
||||||
|
var total = await withQty.CountAsync();
|
||||||
|
var pageRows = await withQty
|
||||||
|
.OrderBy(x => x.Item.Name)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Item.Id, x.Item.Sku, x.Item.Name,
|
||||||
|
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
|
||||||
|
Category = x.Item.Category.Name,
|
||||||
|
Uom = x.Item.BaseUom.Name,
|
||||||
|
x.Item.CostPrice, x.Item.ReorderLevel,
|
||||||
|
x.Qty, x.Wh,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var ids = pageRows.Select(r => r.Id).ToList();
|
||||||
|
var toExclusive = f.To.TimeOfDay == TimeSpan.Zero ? f.To.AddDays(1) : f.To;
|
||||||
|
var from = f.From;
|
||||||
|
|
||||||
|
Expression<Func<StockMovement, bool>> isIn, isOut;
|
||||||
|
if (f.LocationId.HasValue)
|
||||||
|
{
|
||||||
|
var loc = f.LocationId.Value;
|
||||||
|
isIn = m => m.ToLocationId == loc;
|
||||||
|
isOut = m => m.FromLocationId == loc;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// All locations: only movements that cross the boundary of the internal network count;
|
||||||
|
// warehouse <-> vehicle transfers net to zero and are left out of in/out.
|
||||||
|
isIn = m => m.ToLocationId != null && m.ToLocationType != LocationType.External
|
||||||
|
&& (m.FromLocationId == null || m.FromLocationType == LocationType.External);
|
||||||
|
isOut = m => m.FromLocationId != null && m.FromLocationType != LocationType.External
|
||||||
|
&& (m.ToLocationId == null || m.ToLocationType == LocationType.External);
|
||||||
|
}
|
||||||
|
|
||||||
|
var moves = db.StockMovements.AsNoTracking().Where(m => ids.Contains(m.FK_ItemId) && m.CreatedAt < toExclusive);
|
||||||
|
var before = moves.Where(m => m.CreatedAt < from);
|
||||||
|
var inRange = moves.Where(m => m.CreatedAt >= from);
|
||||||
|
|
||||||
|
var openingIn = await SumByItem(before, isIn);
|
||||||
|
var openingOut = await SumByItem(before, isOut);
|
||||||
|
var rangeIn = await SumByItem(inRange, isIn);
|
||||||
|
var rangeOut = await SumByItem(inRange, isOut);
|
||||||
|
|
||||||
|
var rows = pageRows.Select(r => new LedgerAggregate(
|
||||||
|
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.Uom, r.CostPrice, r.ReorderLevel,
|
||||||
|
openingIn.GetValueOrDefault(r.Id) - openingOut.GetValueOrDefault(r.Id),
|
||||||
|
rangeIn.GetValueOrDefault(r.Id), rangeOut.GetValueOrDefault(r.Id),
|
||||||
|
r.Qty, r.Wh)).ToList();
|
||||||
|
return (rows, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<Guid, decimal>> SumByItem(
|
||||||
|
IQueryable<StockMovement> q, Expression<Func<StockMovement, bool>> predicate) =>
|
||||||
|
await q.Where(predicate)
|
||||||
|
.GroupBy(m => m.FK_ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, Qty = g.Sum(m => m.Quantity) })
|
||||||
|
.ToDictionaryAsync(x => x.ItemId, x => x.Qty);
|
||||||
|
|
||||||
|
public async Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(
|
||||||
|
Guid? locationId, string? search, DateTime cutoffExclusive, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var q = ScopedBalances(locationId)
|
||||||
|
.Where(b => b.FK_ItemBatchId != null && b.Quantity > 0
|
||||||
|
&& b.ItemBatch!.ExpiryDate != null && b.ItemBatch.ExpiryDate < cutoffExclusive);
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var s = search.Trim();
|
||||||
|
q = q.Where(b => b.Item.Name.Contains(s) || b.Item.Sku.Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync();
|
||||||
|
var page_ = await q
|
||||||
|
.OrderBy(b => b.ItemBatch!.ExpiryDate).ThenBy(b => b.Item.Name)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.Select(b => new
|
||||||
|
{
|
||||||
|
b.FK_ItemId, b.Item.Sku, b.Item.Name,
|
||||||
|
BatchId = b.FK_ItemBatchId!.Value,
|
||||||
|
b.ItemBatch!.BatchNumber,
|
||||||
|
ExpiryDate = b.ItemBatch.ExpiryDate!.Value,
|
||||||
|
b.Quantity, b.Item.CostPrice, b.LocationId, b.LocationType,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var locIds = page_.Select(r => r.LocationId).Distinct().ToList();
|
||||||
|
var names = await db.Warehouses.AsNoTracking().Where(w => locIds.Contains(w.Id))
|
||||||
|
.Select(w => new { w.Id, w.Name }).ToDictionaryAsync(w => w.Id, w => w.Name);
|
||||||
|
var plates = await db.Vehicles.AsNoTracking().Where(v => locIds.Contains(v.Id))
|
||||||
|
.Select(v => new { v.Id, v.NumberPlate }).ToDictionaryAsync(v => v.Id, v => v.NumberPlate);
|
||||||
|
|
||||||
|
var rows = page_.Select(r => new ExpiryAggregate(
|
||||||
|
r.FK_ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate,
|
||||||
|
r.Quantity, r.CostPrice, r.LocationId, r.LocationType.ToString(),
|
||||||
|
names.GetValueOrDefault(r.LocationId) ?? plates.GetValueOrDefault(r.LocationId) ?? "—")).ToList();
|
||||||
|
return (rows, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Distribution.Features.Stock;
|
||||||
|
|
||||||
|
namespace Distribution.Features.StockReport;
|
||||||
|
|
||||||
|
public class StockReportService(IStockReportRepository repo)
|
||||||
|
{
|
||||||
|
public const int MaxRangeDays = 366;
|
||||||
|
public const int MaxExpiryDays = 3650;
|
||||||
|
|
||||||
|
public static string? Validate(StockReportFilter f, int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
|
||||||
|
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.";
|
||||||
|
if (f.Status is not (null or "low" or "out")) return "status must be 'low' or 'out'.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ValidateExpiry(int days, int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
|
||||||
|
if (days < 0 || days > MaxExpiryDays) return $"days must be between 0 and {MaxExpiryDays}.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-stock is judged on warehouse quantity, so it only applies when the scope includes warehouses.
|
||||||
|
private async Task<bool> IncludesWarehouses(Guid? locationId) =>
|
||||||
|
!locationId.HasValue || await repo.IsWarehouseAsync(locationId.Value);
|
||||||
|
|
||||||
|
public async Task<StockOverview> GetOverviewAsync(Guid? locationId, int expiryDays)
|
||||||
|
{
|
||||||
|
var agg = await repo.GetOverviewAsync(locationId, await IncludesWarehouses(locationId), DateTime.Now.Date, expiryDays);
|
||||||
|
return new StockOverview(agg.InStockItems, agg.TotalQty, agg.TotalValue, agg.LowStockItems,
|
||||||
|
agg.ActiveItems - agg.InStockItems, agg.ExpiringSoonBatches, agg.ExpiredBatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<StockLedgerRow>> GetLedgerAsync(StockReportFilter f, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var includeLow = await IncludesWarehouses(f.LocationId);
|
||||||
|
var (rows, total) = await repo.GetLedgerAsync(f, includeLow, page, pageSize);
|
||||||
|
var mapped = rows.Select(r =>
|
||||||
|
{
|
||||||
|
var status = r.Current <= 0 ? StockStatus.Out
|
||||||
|
: includeLow && r.ReorderLevel != null && r.WarehouseQty <= r.ReorderLevel ? StockStatus.Low
|
||||||
|
: StockStatus.Ok;
|
||||||
|
return new StockLedgerRow(r.ItemId, r.Sku, r.Name, r.Brand, r.Category, r.Uom,
|
||||||
|
r.Opening, r.In, r.Out, r.Opening + r.In - r.Out, r.Current,
|
||||||
|
r.CostPrice, r.Current * (r.CostPrice ?? 0m), r.ReorderLevel, status);
|
||||||
|
}).ToList();
|
||||||
|
return new PagedResponse<StockLedgerRow>(page, pageSize, total, mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<StockExpiryRow>> GetExpiryAsync(
|
||||||
|
Guid? locationId, string? search, int days, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var today = DateTime.Now.Date;
|
||||||
|
var (rows, total) = await repo.GetExpiryAsync(locationId, search, today.AddDays(days + 1), page, pageSize);
|
||||||
|
var mapped = rows.Select(r => new StockExpiryRow(
|
||||||
|
r.ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate,
|
||||||
|
(int)(r.ExpiryDate.Date - today).TotalDays,
|
||||||
|
r.Quantity, r.CostPrice, r.Quantity * (r.CostPrice ?? 0m),
|
||||||
|
r.LocationId, r.LocationType, r.LocationName)).ToList();
|
||||||
|
return new PagedResponse<StockExpiryRow>(page, pageSize, total, mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,5 @@ public interface IStoreRepository
|
|||||||
void Update(Models.Store entity);
|
void Update(Models.Store entity);
|
||||||
void Remove(Models.Store entity);
|
void Remove(Models.Store entity);
|
||||||
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
|
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
|
||||||
|
Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ public class StoreController(
|
|||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId;
|
var userId = currentUser.UserId;
|
||||||
if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
|
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
|
||||||
return StatusCode(403);
|
// return StatusCode(403);
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
@@ -112,6 +112,13 @@ public class StoreController(
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/analytics")]
|
||||||
|
public async Task<IActionResult> GetAnalytics(Guid id, [FromQuery] int months = 12, [FromQuery] int overdueDays = 30)
|
||||||
|
{
|
||||||
|
var result = await service.GetAnalyticsAsync(id, months, overdueDays);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/status")]
|
[HttpPatch("{id:guid}/status")]
|
||||||
[Authorize("store.manage")]
|
[Authorize("store.manage")]
|
||||||
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
|
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -59,3 +59,40 @@ public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
|||||||
public record PatchStoreStatusRequest(bool IsActive);
|
public record PatchStoreStatusRequest(bool IsActive);
|
||||||
|
|
||||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||||
|
|
||||||
|
public record StoreAnalyticsSummary(
|
||||||
|
decimal TotalSales,
|
||||||
|
decimal TotalCollected,
|
||||||
|
decimal Outstanding,
|
||||||
|
decimal OverdueAmount,
|
||||||
|
int OverdueInvoiceCount,
|
||||||
|
int InvoiceCount,
|
||||||
|
decimal AverageOrderValue,
|
||||||
|
DateTime? LastSaleDate,
|
||||||
|
decimal CreditLimit,
|
||||||
|
decimal CreditUtilizationPercent);
|
||||||
|
|
||||||
|
public record StoreMonthlySales(string Month, decimal Invoiced, decimal Collected, decimal Outstanding);
|
||||||
|
|
||||||
|
public record StoreTopProduct(Guid ItemId, string Name, int Quantity, decimal Revenue, DateTime LastSoldAt);
|
||||||
|
|
||||||
|
public record StoreAgeingBucket(string Label, decimal Amount, int InvoiceCount);
|
||||||
|
|
||||||
|
public record StoreAnalyticsInvoice(
|
||||||
|
Guid InvoiceId,
|
||||||
|
DateTime InvoiceDate,
|
||||||
|
DateTime DueDate,
|
||||||
|
decimal TotalAmount,
|
||||||
|
decimal PaidAmount,
|
||||||
|
decimal Balance,
|
||||||
|
string PaymentStatus,
|
||||||
|
int DaysOverdue);
|
||||||
|
|
||||||
|
public record StoreAnalyticsResponse(
|
||||||
|
Guid StoreId,
|
||||||
|
int OverdueDays,
|
||||||
|
StoreAnalyticsSummary Summary,
|
||||||
|
List<StoreMonthlySales> MonthlySales,
|
||||||
|
List<StoreTopProduct> TopProducts,
|
||||||
|
List<StoreAgeingBucket> Ageing,
|
||||||
|
List<StoreAnalyticsInvoice> Invoices);
|
||||||
|
|||||||
@@ -51,4 +51,12 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
|
|||||||
await context.ShopPOs
|
await context.ShopPOs
|
||||||
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
|
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId) =>
|
||||||
|
await context.Invoices
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(i => i.Payments)
|
||||||
|
.Include(i => i.ShopPO).ThenInclude(p => p.Items).ThenInclude(x => x.Item)
|
||||||
|
.Where(i => i.IsActive && i.Status != InvoiceStatus.Draft && i.ShopPO.FK_StoreId == storeId)
|
||||||
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user