Compare commits
42 Commits
| 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 |
@@ -0,0 +1,209 @@
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Distribution.Tests.Services;
|
||||
|
||||
public class NotificationServiceTests
|
||||
{
|
||||
private readonly Mock<INotificationRepository> _repo = new();
|
||||
private readonly Mock<IUnitOfWork> _uow = new();
|
||||
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
|
||||
private readonly Mock<IHubClients> _clients = new();
|
||||
private readonly Mock<IClientProxy> _proxy = new();
|
||||
private readonly NotificationService _sut;
|
||||
|
||||
public NotificationServiceTests()
|
||||
{
|
||||
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
|
||||
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
|
||||
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
|
||||
|
||||
_sut = new NotificationService(
|
||||
_repo.Object,
|
||||
new NotificationRecipientResolver(_repo.Object),
|
||||
_hub.Object,
|
||||
_uow.Object,
|
||||
NullLogger<NotificationService>.Instance);
|
||||
}
|
||||
|
||||
private static NotificationArgs Args(Guid? actor = null) =>
|
||||
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
|
||||
|
||||
// Registry
|
||||
|
||||
[Fact]
|
||||
public void EveryNotificationTypeConstant_HasADefinition()
|
||||
{
|
||||
var constants = typeof(NotificationTypes)
|
||||
.GetFields()
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(constants);
|
||||
Assert.Equal(constants.Count, constants.Distinct().Count());
|
||||
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
|
||||
}
|
||||
|
||||
// NotifyAsync
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
|
||||
{
|
||||
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
|
||||
|
||||
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
|
||||
{
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
|
||||
|
||||
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_ExcludesTheActingUser()
|
||||
{
|
||||
var actor = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||
[RecipientRule.Users(actor, other)], Args(actor));
|
||||
|
||||
Assert.NotNull(saved);
|
||||
var recipient = Assert.Single(saved!.Recipients);
|
||||
Assert.Equal(other, recipient.FK_UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
|
||||
{
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
|
||||
|
||||
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_clients.Verify(c => c.User(a.ToString()), Times.Once);
|
||||
_clients.Verify(c => c.User(b.ToString()), Times.Once);
|
||||
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
|
||||
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
|
||||
{
|
||||
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
|
||||
|
||||
var ex = await Record.ExceptionAsync(() =>
|
||||
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||
|
||||
Assert.Null(ex);
|
||||
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
|
||||
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
|
||||
{
|
||||
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("socket gone"));
|
||||
|
||||
var ex = await Record.ExceptionAsync(() =>
|
||||
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||
|
||||
Assert.Null(ex);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
|
||||
}
|
||||
|
||||
// Recipient rules
|
||||
|
||||
[Fact]
|
||||
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
|
||||
{
|
||||
var both = Guid.NewGuid();
|
||||
var permissionOnly = Guid.NewGuid();
|
||||
var scopeOnly = Guid.NewGuid();
|
||||
var storeId = Guid.NewGuid();
|
||||
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
|
||||
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
|
||||
|
||||
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
|
||||
|
||||
Assert.Equal([both], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
var supervisor = Guid.NewGuid();
|
||||
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||
|
||||
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
|
||||
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||
|
||||
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
|
||||
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
|
||||
{
|
||||
var storeId = Guid.NewGuid();
|
||||
var owner = Guid.NewGuid();
|
||||
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||
|
||||
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
|
||||
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||
|
||||
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
|
||||
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
|
||||
{
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
|
||||
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
|
||||
|
||||
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_DeduplicatesUsersAcrossRules()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
|
||||
|
||||
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
|
||||
|
||||
Assert.Equal([user], ids);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||
public DbSet<Invoice> Invoices { get; set; }
|
||||
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||||
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
|
||||
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
|
||||
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||
public DbSet<User> Users { get; set; }
|
||||
@@ -71,6 +74,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
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)
|
||||
@@ -173,6 +183,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(i => i.FK_ShopPOId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.Invoice)
|
||||
.WithMany(inv => inv.Items)
|
||||
.HasForeignKey(i => i.FK_InvoiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.ShopPOItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersion>()
|
||||
.HasOne(v => v.Invoice)
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.FK_InvoiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersion>()
|
||||
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.InvoiceVersion)
|
||||
.WithMany(v => v.Items)
|
||||
.HasForeignKey(i => i.FK_InvoiceVersionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.ShopPOItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasMany(i => i.Payments)
|
||||
.WithOne(p => p.Invoice)
|
||||
@@ -708,6 +766,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
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>()
|
||||
@@ -736,7 +818,88 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
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.ExpenseSeed.Apply(modelBuilder);
|
||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||
Seed.OperationsDataSeed.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 PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||
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)
|
||||
{
|
||||
@@ -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 = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
|
||||
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
|
||||
@@ -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 = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,27 @@ namespace Distribution.Features.DashboardReport;
|
||||
|
||||
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
||||
|
||||
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||
|
||||
public record SalesKpis(
|
||||
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||
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, List<TrendPoint> Trend,
|
||||
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
|
||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||
|
||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||
|
||||
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
public Guid RepId { get; set; }
|
||||
}
|
||||
|
||||
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
|
||||
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||
// 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.ShopPOItems.AsNoTracking()
|
||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||
where inv.Status == InvoiceStatus.Finalized
|
||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
|
||||
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 = inv.FinalizedAt!.Value.Date,
|
||||
PoId = li.FK_ShopPOId,
|
||||
Value = li.QuantityFulfilled * li.UnitPrice,
|
||||
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
|
||||
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.ShopPO.Store.Route.FK_RegionId,
|
||||
RegionName = li.ShopPO.Store.Route.Region.Name,
|
||||
RepId = li.ShopPO.RaisedById,
|
||||
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);
|
||||
@@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
{
|
||||
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);
|
||||
.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;
|
||||
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
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, orders, costedSales, cost);
|
||||
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) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
|
||||
public class DashboardReportService(IDashboardReportRepository repo)
|
||||
{
|
||||
public const int MaxRangeDays = 366;
|
||||
private const int DailyGranularityMaxDays = 62;
|
||||
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)
|
||||
{
|
||||
@@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
|
||||
private static SalesKpis ToKpis(SalesTotals t)
|
||||
{
|
||||
var net = t.GrossSales - t.ReturnValue;
|
||||
var net = t.GrossSales - t.ReturnValue - t.Discounts;
|
||||
var gp = t.CostedSales - t.CostOfSales;
|
||||
return new SalesKpis(
|
||||
t.GrossSales, t.ReturnValue, net, t.Orders,
|
||||
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;
|
||||
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
|
||||
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);
|
||||
|
||||
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
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(),
|
||||
|
||||
@@ -5,6 +5,7 @@ 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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.LoadingEvent;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
|
||||
IShopPORepository shopPORepo,
|
||||
ILoadingSheetRepository loadingSheetRepo,
|
||||
StockMover mover,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string DeliveriesLink = "/operations/deliveries";
|
||||
|
||||
// Route owner + managers whose scope covers the store being delivered to.
|
||||
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
|
||||
[
|
||||
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
|
||||
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
|
||||
];
|
||||
|
||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||
new(t.Id,
|
||||
t.FK_RideId,
|
||||
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
if (!fullyDelivered && shopPO.Store is not null)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.DeliveryTaskPartiallyDelivered,
|
||||
DeliveryRecipients(shopPO.FK_StoreId),
|
||||
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
|
||||
ct);
|
||||
|
||||
var saved = await repo.GetByIdAsync(taskId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
if (task.ShopPO?.Store is not null)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.DeliveryTaskFailed,
|
||||
DeliveryRecipients(task.ShopPO.FK_StoreId),
|
||||
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
|
||||
ct);
|
||||
|
||||
return ToResponse(task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||
Task AddAsync(Models.Invoice invoice);
|
||||
Task<Models.Item?> GetItemAsync(Guid itemId);
|
||||
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
|
||||
Task AddVersionAsync(InvoiceVersion version);
|
||||
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
|
||||
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
|
||||
void Update(Models.Invoice invoice);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,52 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/preview-totals")]
|
||||
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var result = await service.PreviewTotalsAsync(id, request);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/versions")]
|
||||
public async Task<IActionResult> GetVersions(Guid id)
|
||||
{
|
||||
var result = await service.GetVersionsAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items")]
|
||||
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.EditedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/print")]
|
||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Invoice;
|
||||
|
||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
||||
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
|
||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
|
||||
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
|
||||
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
|
||||
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
|
||||
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
// Quantity = how many free units the scheme still entitles for the lines as typed.
|
||||
public record FreeEntitlement(Guid SchemeId, int Quantity);
|
||||
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
|
||||
|
||||
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
|
||||
|
||||
public record InvoiceVersionResponse(
|
||||
Guid Id,
|
||||
int VersionNumber,
|
||||
InvoiceVersionStage Stage,
|
||||
decimal TotalAmount,
|
||||
Guid CreatedBy,
|
||||
DateTime CreatedAt,
|
||||
List<InvoiceVersionItemResponse> Items);
|
||||
|
||||
public record InvoiceItemResponse(
|
||||
Guid Id,
|
||||
Guid? ShopPOItemId,
|
||||
Guid ItemId,
|
||||
string ItemName,
|
||||
int? OrderedQuantity,
|
||||
int Quantity,
|
||||
decimal UnitPrice,
|
||||
bool IsRemoved,
|
||||
bool IsFree = false,
|
||||
Guid? SchemeId = null,
|
||||
string? SchemeName = null);
|
||||
|
||||
public record InvoiceResponse(
|
||||
Guid Id,
|
||||
Guid ShopPOId,
|
||||
@@ -20,8 +56,11 @@ public record InvoiceResponse(
|
||||
DateTime? FinalizedAt,
|
||||
Guid? FinalizedBy,
|
||||
decimal TotalAmount,
|
||||
decimal DiscountAmount,
|
||||
decimal PaidAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
DateTime CreatedAt,
|
||||
List<InvoiceItemResponse> Items,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
|
||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||
|
||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
|
||||
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
|
||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||
|
||||
@@ -49,9 +51,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
|
||||
await context.SchemeRedemptions
|
||||
.Where(r => r.FK_ShopPOId == shopPOId)
|
||||
.SumAsync(r => r.DiscountAmount) ?? 0m;
|
||||
|
||||
public async Task AddAsync(Models.Invoice invoice) =>
|
||||
await context.Invoices.AddAsync(invoice);
|
||||
|
||||
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
|
||||
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
|
||||
|
||||
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
|
||||
await context.InvoiceItems.AddRangeAsync(items);
|
||||
|
||||
public async Task AddVersionAsync(InvoiceVersion version) =>
|
||||
await context.InvoiceVersions.AddAsync(version);
|
||||
|
||||
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
|
||||
await context.InvoiceVersions
|
||||
.Where(v => v.FK_InvoiceId == invoiceId)
|
||||
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
|
||||
|
||||
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
|
||||
await context.InvoiceVersions
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
|
||||
.OrderBy(v => v.VersionNumber)
|
||||
.ToListAsync();
|
||||
|
||||
public void Update(Models.Invoice invoice) =>
|
||||
context.Invoices.Update(invoice);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -7,6 +9,8 @@ namespace Distribution.Features.Invoice;
|
||||
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IShopPORepository shopPoRepo,
|
||||
SchemeService schemeService,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
@@ -17,14 +21,114 @@ public class InvoiceService(
|
||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||
i.PrintedAt, i.PrintedBy,
|
||||
i.FinalizedAt, i.FinalizedBy,
|
||||
i.TotalAmount,
|
||||
i.TotalAmount, i.DiscountAmount,
|
||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
||||
i.IsActive, i.CreatedAt);
|
||||
i.IsActive, i.CreatedAt,
|
||||
i.Items.Where(x => x.IsActive).Select(x =>
|
||||
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
||||
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
||||
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
|
||||
|
||||
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||
|
||||
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
|
||||
// scheme rules ignore them.
|
||||
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved)
|
||||
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
|
||||
.ToList();
|
||||
|
||||
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
|
||||
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
|
||||
// and at print/finalize. The redemption amount is updated with it.
|
||||
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||
{
|
||||
var lines = items.ToList();
|
||||
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
|
||||
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
|
||||
var billable = BillableTotal(lines);
|
||||
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
|
||||
invoice.DiscountAmount = Math.Min(discount, billable);
|
||||
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||
}
|
||||
|
||||
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
|
||||
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
if (invoice is null) return null;
|
||||
|
||||
var lines = request.Items.Where(l => !l.IsRemoved)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
|
||||
.ToList();
|
||||
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
|
||||
var chosen = request.DiscountSchemeIds is null
|
||||
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
|
||||
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
|
||||
var discount = Math.Min(chosen, billable);
|
||||
|
||||
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
|
||||
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
|
||||
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
|
||||
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
|
||||
}
|
||||
|
||||
// Immutable copy of the invoice lines as they stand right now.
|
||||
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
||||
new()
|
||||
{
|
||||
FK_InvoiceId = invoice.Id,
|
||||
VersionNumber = versionNumber,
|
||||
Stage = stage,
|
||||
TotalAmount = invoice.TotalAmount,
|
||||
CreatedBy = userId,
|
||||
Items = items.Select(i => new InvoiceVersionItem
|
||||
{
|
||||
FK_ShopPOItemId = i.FK_ShopPOItemId,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
IsRemoved = i.IsRemoved
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
|
||||
{
|
||||
if (await repo.GetByIdAsync(id) is null) return null;
|
||||
var versions = await repo.GetVersionsAsync(id);
|
||||
return versions.Select(v => new InvoiceVersionResponse(
|
||||
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
|
||||
v.Items.Select(i => new InvoiceVersionItemResponse(
|
||||
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
|
||||
}
|
||||
|
||||
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
|
||||
{
|
||||
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
|
||||
// Added lines may or may not be in invoice.Items yet, so merge by reference.
|
||||
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
|
||||
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
|
||||
}
|
||||
|
||||
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
|
||||
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
|
||||
{
|
||||
FK_InvoiceId = invoiceId,
|
||||
FK_ShopPOItemId = i.Id,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
IsFree = i.IsFree,
|
||||
FK_SchemeId = i.FK_SchemeId,
|
||||
SchemeName = i.SchemeName
|
||||
}).ToList();
|
||||
|
||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
return invoice is null ? null : ToResponse(invoice);
|
||||
if (invoice is null) return null;
|
||||
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
||||
@@ -42,6 +146,131 @@ public class InvoiceService(
|
||||
return new OutstandingPaymentsResponse(count, outstanding);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
||||
{
|
||||
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.ConfirmedAt is null)
|
||||
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
||||
if (po.InvoiceId is not null)
|
||||
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
||||
|
||||
var invoice = new Models.Invoice
|
||||
{
|
||||
FK_ShopPOId = po.Id,
|
||||
Status = InvoiceStatus.Draft,
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||
await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(invoice);
|
||||
await repo.AddItemsAsync(lines);
|
||||
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
||||
po.InvoiceId = invoice.Id;
|
||||
shopPoRepo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(invoice.Id);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Invoice not found");
|
||||
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
|
||||
|
||||
var newLines = new List<InvoiceItem>();
|
||||
|
||||
foreach (var edit in request.Items)
|
||||
{
|
||||
if (edit.UnitPrice is < 0)
|
||||
throw new InvalidOperationException("Unit price cannot be negative");
|
||||
|
||||
if (edit.InvoiceItemId is Guid lineId)
|
||||
{
|
||||
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
|
||||
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
|
||||
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
|
||||
|
||||
item.IsRemoved = edit.IsRemoved;
|
||||
if (!edit.IsRemoved)
|
||||
{
|
||||
ValidateQuantity(item, edit.Quantity);
|
||||
item.Quantity = edit.Quantity;
|
||||
}
|
||||
if (edit.UnitPrice is decimal price)
|
||||
{
|
||||
if (item.IsFree && price != 0m)
|
||||
throw new InvalidOperationException("A free-issue line must stay at zero price");
|
||||
item.UnitPrice = price;
|
||||
}
|
||||
}
|
||||
else if (edit.ItemId is Guid itemId)
|
||||
{
|
||||
// New line for an item that was not on the PO.
|
||||
if (edit.IsRemoved) continue;
|
||||
if (edit.Quantity <= 0)
|
||||
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
|
||||
if (edit.UnitPrice is null)
|
||||
throw new InvalidOperationException("Unit price is required for an added item");
|
||||
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
|
||||
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
|
||||
|
||||
var catalogItem = await repo.GetItemAsync(itemId)
|
||||
?? throw new InvalidOperationException($"Item {itemId} not found");
|
||||
|
||||
newLines.Add(new InvoiceItem
|
||||
{
|
||||
FK_InvoiceId = invoice.Id,
|
||||
FK_ItemId = catalogItem.Id,
|
||||
Item = catalogItem,
|
||||
Quantity = edit.Quantity,
|
||||
UnitPrice = edit.UnitPrice.Value
|
||||
});
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||
}
|
||||
|
||||
var allLines = invoice.Items.Concat(newLines).ToList();
|
||||
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
||||
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
||||
await ApplyTotalsAsync(invoice, allLines);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(id))!;
|
||||
}
|
||||
|
||||
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||
{
|
||||
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
|
||||
if (quantity < 0 || quantity > ordered)
|
||||
throw new InvalidOperationException(
|
||||
$"Quantity for item {item.Id} must be between 0 and {ordered}");
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
@@ -50,12 +279,21 @@ public class InvoiceService(
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||
|
||||
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
||||
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
||||
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
invoice.Status = InvoiceStatus.Printed;
|
||||
invoice.PrintedAt = DateTime.Now;
|
||||
invoice.PrintedBy = printedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(invoice);
|
||||
@@ -69,31 +307,37 @@ public class InvoiceService(
|
||||
if (invoice.Status != InvoiceStatus.Printed)
|
||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||
|
||||
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||
var adjustments = request.Adjustments ?? [];
|
||||
if (adjustments.Count > 0)
|
||||
|
||||
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
||||
foreach (var adjustment in adjustments)
|
||||
{
|
||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
||||
decimal total = 0;
|
||||
foreach (var item in poItems)
|
||||
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||
|
||||
item.IsRemoved = adjustment.IsRemoved;
|
||||
if (!adjustment.IsRemoved)
|
||||
{
|
||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
||||
|
||||
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
||||
throw new InvalidOperationException(
|
||||
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
||||
|
||||
total += billedQuantity * item.UnitPrice;
|
||||
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||
item.Quantity = adjustment.AdjustedQuantity;
|
||||
}
|
||||
invoice.TotalAmount = total;
|
||||
}
|
||||
|
||||
if (items.Count > 0)
|
||||
await ApplyTotalsAsync(invoice, items);
|
||||
|
||||
invoice.Status = InvoiceStatus.Finalized;
|
||||
invoice.FinalizedAt = DateTime.Now;
|
||||
invoice.FinalizedBy = finalizedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(invoice);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
||||
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||
{
|
||||
var lines =
|
||||
from li in db.ShopPOItems.AsNoTracking()
|
||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||
where inv.Status == InvoiceStatus.Finalized
|
||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
from li in db.InvoiceItems.AsNoTracking()
|
||||
where li.IsActive && !li.IsRemoved
|
||||
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
select li;
|
||||
|
||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||
{
|
||||
var s = f.Search.Trim();
|
||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
||||
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||
});
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
||||
// Distinct orders and stores per item are counted separately: MySQL can't run the correlated
|
||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||
var storePairs = await SoldLines(f)
|
||||
var pairs = await SoldLines(f)
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
||||
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
||||
var orderCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.PoId).Distinct().Count());
|
||||
var storeCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.FK_StoreId).Distinct().Count());
|
||||
|
||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
|
||||
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
||||
SoldLines(f).SumAsync(x => x.Quantity * x.UnitPrice);
|
||||
|
||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||
{
|
||||
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
return await SoldLines(f with { Search = null })
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.GroupBy(l => l.FK_ItemId)
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.Quantity) })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.DeliveryRide;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Features.DriverProfile;
|
||||
using Distribution.Infra.UoW;
|
||||
@@ -10,9 +12,14 @@ public class RideService(
|
||||
IRideRepository rideRepo,
|
||||
IDriverProfileRepository driverRepo,
|
||||
IDriverComplianceConfigRepository complianceRepo,
|
||||
IDeliveryTaskRepository taskRepo,
|
||||
StockMover mover,
|
||||
INotificationService notifications,
|
||||
ILogger<RideService> logger,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string RidesLink = "/operations/rides";
|
||||
|
||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||
{
|
||||
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||
@@ -72,6 +79,12 @@ public class RideService(
|
||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.RideDriverAssigned,
|
||||
[RecipientRule.Users(driver.FK_UserId)],
|
||||
new NotificationArgs(null, "Ride", rideId, RidesLink),
|
||||
ct);
|
||||
|
||||
var saved = await rideRepo.GetByIdAsync(rideId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
@@ -125,9 +138,46 @@ public class RideService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyRideCancelledAsync(ride, request, userId, ct);
|
||||
|
||||
return ToResponse(ride);
|
||||
}
|
||||
|
||||
// A ride has no route of its own; the people to tell are the owners and managers of the routes
|
||||
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
|
||||
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
|
||||
var routeIds = tasks
|
||||
.Where(t => t.ShopPO?.Store is not null)
|
||||
.Select(t => t.ShopPO.Store.FK_RouteId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var recipients = new List<RecipientRule>();
|
||||
foreach (var routeId in routeIds)
|
||||
{
|
||||
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
|
||||
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
|
||||
}
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.RideCancelled,
|
||||
recipients,
|
||||
new NotificationArgs(
|
||||
new { CancelType = request.TypeCode, Reason = request.Reason },
|
||||
"Ride", ride.Id, RidesLink, userId),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||
{
|
||||
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
||||
|
||||
@@ -10,7 +10,10 @@ public interface ISchemeRepository
|
||||
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId);
|
||||
void RemoveRedemption(Models.SchemeRedemption entity);
|
||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||
|
||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||
|
||||
@@ -112,6 +112,14 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("suggest")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> Suggest(SuggestSchemesRequest request)
|
||||
{
|
||||
var result = await service.SuggestAsync(request);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/spend")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> GetSpend(Guid id)
|
||||
|
||||
@@ -94,7 +94,13 @@ public record SchemeEligibilityResponse(
|
||||
string? Reason,
|
||||
decimal? DiscountAmount,
|
||||
Guid? FreeItemId,
|
||||
decimal? FreeQty);
|
||||
decimal? FreeQty,
|
||||
string? FreeItemName = null);
|
||||
|
||||
/// <summary>A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list.</summary>
|
||||
public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false);
|
||||
|
||||
public record SuggestSchemesRequest(Guid StoreId, List<SuggestLine> Items);
|
||||
|
||||
public record SchemeRedemptionResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -49,9 +49,25 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||
await context.SchemeRedemptions.AddAsync(entity);
|
||||
|
||||
public async Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId)
|
||||
{
|
||||
var list = await context.SchemeRedemptions.Include(r => r.Scheme).Where(r => r.FK_ShopPOId == shopPOId).ToListAsync();
|
||||
// Rows added earlier in this unit of work are not in the database yet; rows removed are still there.
|
||||
foreach (var added in context.SchemeRedemptions.Local.Where(r => r.FK_ShopPOId == shopPOId && !list.Contains(r)))
|
||||
list.Add(added);
|
||||
return list.Where(r => context.Entry(r).State != EntityState.Deleted).ToList();
|
||||
}
|
||||
|
||||
public void RemoveRedemption(Models.SchemeRedemption entity) =>
|
||||
context.SchemeRedemptions.Remove(entity);
|
||||
|
||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||
|
||||
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
|
||||
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
|
||||
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
|
||||
|
||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||
await context.SchemeBundleComponents
|
||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Store;
|
||||
@@ -14,6 +16,8 @@ public class SchemeService(
|
||||
IItemRepository itemRepo,
|
||||
IStoreRepository storeRepo,
|
||||
IStockRepository stockRepo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -261,12 +265,7 @@ public class SchemeService(
|
||||
|
||||
foreach (var scheme in candidates)
|
||||
{
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
||||
continue;
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
|
||||
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||
continue;
|
||||
|
||||
result.Add(EvaluateRule(scheme, items));
|
||||
@@ -275,6 +274,328 @@ public class SchemeService(
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsInScope(Models.Scheme scheme, string? storeSegment, Guid? storeRegionId, DateTime asOf)
|
||||
{
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != storeRegionId) return false;
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, storeSegment, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return asOf >= scheme.DateRangeFrom && asOf.Date <= scheme.DateRangeTo.Date;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggestions for a Shop PO that is still being built (nothing saved yet). Returns eligible schemes,
|
||||
/// plus BuyXGetY/FreeBundle schemes the cart is part-way to, so the UI can nudge ("add 4 more").
|
||||
/// </summary>
|
||||
public async Task<List<SchemeEligibilityResponse>?> SuggestAsync(SuggestSchemesRequest request)
|
||||
{
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
|
||||
var lines = new List<ShopPOItem>();
|
||||
foreach (var l in request.Items.Where(i => !i.IsFree))
|
||||
{
|
||||
var price = l.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(l.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? 0m;
|
||||
lines.Add(new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = price });
|
||||
}
|
||||
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
var inCart = lines.Select(l => l.FK_ItemId).ToHashSet();
|
||||
var result = new List<SchemeEligibilityResponse>();
|
||||
|
||||
foreach (var scheme in (await repo.GetAllAsync()).Where(s => s.IsActive))
|
||||
{
|
||||
if (!IsInScope(scheme, store.Segment, regionId, DateTime.Now)) continue;
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
var relevant = eval.Eligible || scheme.RuleType switch
|
||||
{
|
||||
SchemeRuleType.BuyXGetY => scheme.FK_TriggerItemId.HasValue && inCart.Contains(scheme.FK_TriggerItemId.Value),
|
||||
SchemeRuleType.FreeBundle => scheme.BundleComponents.Any(c => inCart.Contains(c.FK_ItemId)),
|
||||
_ => false
|
||||
};
|
||||
if (!relevant) continue;
|
||||
|
||||
result.Add(eval with { FreeItemName = scheme.FreeItem?.Name });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the free lines of a Shop PO that is about to be saved: each must reference an in-scope scheme the
|
||||
/// paid lines still qualify for, be the scheme's free item, stay within the entitled qty, and be in stock.
|
||||
/// Stamps the scheme-name snapshot and forces UnitPrice to 0. Read-only; throws InvalidOperationException.
|
||||
/// </summary>
|
||||
public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines)
|
||||
{
|
||||
var freeLines = lines.Where(l => l.IsFree).ToList();
|
||||
if (freeLines.Count == 0) return;
|
||||
|
||||
if (freeLines.Any(l => l.FK_SchemeId is null))
|
||||
throw new InvalidOperationException("A free-issue line must reference the scheme that granted it.");
|
||||
if (freeLines.Any(l => l.Quantity <= 0))
|
||||
throw new InvalidOperationException("Free-issue quantity must be greater than zero (remove the line instead).");
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||
?? throw new InvalidOperationException("Store not found.");
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
|
||||
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(group.Key);
|
||||
if (scheme is null || !scheme.IsActive)
|
||||
throw new InvalidOperationException("A free-issue line references a scheme that no longer exists.");
|
||||
if (!IsInScope(scheme, store.Segment, regionId, asOf))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
if (!eval.Eligible || eval.FreeItemId is null || eval.FreeQty is null)
|
||||
throw new InvalidOperationException(eval.Reason ?? $"Order no longer qualifies for scheme '{scheme.Name}'.");
|
||||
if (group.Any(l => l.FK_ItemId != eval.FreeItemId))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' grants a different free item.");
|
||||
|
||||
var qty = group.Sum(l => l.Quantity);
|
||||
if (qty > eval.FreeQty.Value)
|
||||
throw new InvalidOperationException($"Free quantity {qty} exceeds the {eval.FreeQty.Value:0.##} entitled by scheme '{scheme.Name}'.");
|
||||
|
||||
var available = (await stockRepo.GetBalancesByItemAsync(eval.FreeItemId.Value)).Sum(b => b.Quantity);
|
||||
if (available < qty)
|
||||
throw new InvalidOperationException($"Insufficient stock to give {qty} free of scheme '{scheme.Name}'.");
|
||||
|
||||
foreach (var l in group)
|
||||
{
|
||||
l.UnitPrice = 0m;
|
||||
l.SchemeName = scheme.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the percentage-discount schemes (FlatPercent / SlabDiscount) the user accepted for a Shop PO that is
|
||||
/// about to be saved: one at most, in scope, and the paid lines must still qualify. Read-only; throws.
|
||||
/// </summary>
|
||||
public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines, List<Guid>? schemeIds)
|
||||
{
|
||||
if (schemeIds is not { Count: > 0 }) return;
|
||||
if (schemeIds.Distinct().Count() > 1)
|
||||
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||
?? throw new InvalidOperationException("Store not found.");
|
||||
|
||||
var scheme = await repo.GetByIdAsync(schemeIds[0]);
|
||||
if (scheme is null || !scheme.IsActive)
|
||||
throw new InvalidOperationException("The selected discount scheme no longer exists.");
|
||||
if (scheme.RuleType is not (SchemeRuleType.FlatPercent or SchemeRuleType.SlabDiscount))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not a discount scheme.");
|
||||
if (!IsInScope(scheme, store.Segment, store.Route?.FK_RegionId, asOf))
|
||||
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||
|
||||
var eval = EvaluateRule(scheme, lines);
|
||||
if (!eval.Eligible)
|
||||
throw new InvalidOperationException(eval.Reason ?? $"Order does not qualify for scheme '{scheme.Name}'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates the percentage discounts redeemed against a Shop PO for the given (invoice) lines and returns
|
||||
/// their total. With persist = true the redemption amounts are updated too (the caller commits), so spend/ROI and
|
||||
/// the invoice stay in step; a discount the lines no longer qualify for drops to 0 and comes back if they requalify.
|
||||
/// </summary>
|
||||
public async Task<decimal> CalculateDiscountAsync(Guid shopPOId, IEnumerable<ShopPOItem> lines, bool persist)
|
||||
{
|
||||
var priced = lines.ToList();
|
||||
decimal total = 0m;
|
||||
foreach (var r in (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue))
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||
var amount = eval is { Eligible: true, DiscountAmount: not null } ? eval.DiscountAmount.Value : 0m;
|
||||
if (persist) r.DiscountAmount = amount;
|
||||
total += amount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// How many free units each scheme currently entitles for these paid lines (0 when no longer eligible).
|
||||
private async Task<Dictionary<Guid, int>> FreeEntitlementsAsync(IEnumerable<Guid> schemeIds, List<ShopPOItem> paidLines)
|
||||
{
|
||||
var result = new Dictionary<Guid, int>();
|
||||
foreach (var id in schemeIds.Distinct())
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, paidLines);
|
||||
result[id] = eval is { Eligible: true, FreeQty: not null } ? (int)Math.Floor(eval.FreeQty.Value) : 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Free-issue entitlement per scheme for unsaved invoice lines (editor preview). Lines carry IsFree / scheme id;
|
||||
/// only the paid ones count toward the scheme rules.
|
||||
/// </summary>
|
||||
public async Task<Dictionary<Guid, int>> GetFreeEntitlementsAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines) =>
|
||||
await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Keeps an invoice's free-issue lines within what the remaining paid lines still earn: when the paid quantity of the
|
||||
/// qualifying item is lowered (or removed), the free quantity is reduced to match (a line that earns nothing is struck
|
||||
/// out) and the PO's redemption is resized. Stages changes only. Returns true if any free line was changed.
|
||||
/// </summary>
|
||||
public async Task<bool> ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List<InvoiceItem> lines)
|
||||
{
|
||||
var freeLines = lines.Where(l => l.IsActive && l.IsFree && l.FK_SchemeId.HasValue).ToList();
|
||||
if (freeLines.Count == 0) return false;
|
||||
|
||||
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||
.ToList();
|
||||
var entitled = await FreeEntitlementsAsync(freeLines.Select(l => l.FK_SchemeId!.Value), paid);
|
||||
var redemptions = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||
var changed = false;
|
||||
|
||||
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||
{
|
||||
var remaining = entitled[group.Key];
|
||||
foreach (var line in group.Where(l => !l.IsRemoved).OrderBy(l => l.CreatedAt))
|
||||
{
|
||||
if (line.Quantity <= remaining) { remaining -= line.Quantity; continue; }
|
||||
|
||||
if (remaining == 0) line.IsRemoved = true;
|
||||
else line.Quantity = remaining;
|
||||
remaining = 0;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var redemption = redemptions.FirstOrDefault(r => r.FK_SchemeId == group.Key && r.FK_FreeItemId.HasValue);
|
||||
if (redemption is not null)
|
||||
redemption.FreeQty = group.Where(l => !l.IsRemoved).Sum(l => l.Quantity);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discount the given schemes would give for these lines, without touching any redemption (editor preview).
|
||||
/// </summary>
|
||||
public async Task<decimal> CalculateDiscountForSchemesAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines)
|
||||
{
|
||||
var priced = lines.ToList();
|
||||
decimal total = 0m;
|
||||
foreach (var id in schemeIds.Distinct())
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null }) total += eval.DiscountAmount.Value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the discount choice made on a Draft invoice (the percentage scheme the user accepted, or none) to the
|
||||
/// PO's redemptions: newly chosen schemes are validated and added, unchosen ones removed. The amount itself is
|
||||
/// then set by CalculateDiscountAsync when the totals are re-derived. Stages changes only. Null = leave as is.
|
||||
/// </summary>
|
||||
public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List<InvoiceItem> lines, List<Guid>? schemeIds)
|
||||
{
|
||||
if (schemeIds is null) return;
|
||||
if (schemeIds.Distinct().Count() > 1)
|
||||
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||
|
||||
var existing = (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue).ToList();
|
||||
foreach (var r in existing.Where(r => !schemeIds.Contains(r.FK_SchemeId)))
|
||||
repo.RemoveRedemption(r);
|
||||
|
||||
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||
.ToList();
|
||||
var added = schemeIds.Where(id => existing.All(r => r.FK_SchemeId != id)).ToList();
|
||||
await ValidateDiscountSchemesAsync(storeId, DateTime.Now, paid, added);
|
||||
|
||||
foreach (var id in added)
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = id, FK_ShopPOId = shopPOId, DiscountAmount = 0m });
|
||||
}
|
||||
|
||||
/// <summary>Discounts already redeemed against a Shop PO, for display and for editing.</summary>
|
||||
public async Task<List<ShopPODiscountResponse>> GetDiscountsForShopPOAsync(Guid shopPOId) =>
|
||||
(await repo.GetRedemptionsByShopPOAsync(shopPOId))
|
||||
.Where(r => r.DiscountAmount.HasValue)
|
||||
.Select(r => new ShopPODiscountResponse(r.FK_SchemeId, r.Scheme?.Name ?? string.Empty, r.DiscountAmount!.Value))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Brings the Shop PO's scheme redemptions in line with its saved lines: free-goods redemptions follow the
|
||||
/// free lines (created, resized or removed), discount redemptions are recomputed or dropped if the order no
|
||||
/// longer qualifies. Stages changes only — the caller owns the commit.
|
||||
/// </summary>
|
||||
public async Task SyncRedemptionsAsync(Guid shopPOId, List<ShopPOItem> lines, List<Guid>? discountSchemeIds = null)
|
||||
{
|
||||
var existing = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||
var freeBySchemes = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue)
|
||||
.GroupBy(l => l.FK_SchemeId!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var r in existing)
|
||||
{
|
||||
if (r.FK_FreeItemId.HasValue)
|
||||
{
|
||||
if (!freeBySchemes.ContainsKey(r.FK_SchemeId)) repo.RemoveRedemption(r);
|
||||
}
|
||||
else if (r.DiscountAmount.HasValue)
|
||||
{
|
||||
// A null list means "leave the accepted discounts alone" (just recompute them).
|
||||
if (discountSchemeIds is not null && !discountSchemeIds.Contains(r.FK_SchemeId))
|
||||
{
|
||||
repo.RemoveRedemption(r);
|
||||
continue;
|
||||
}
|
||||
|
||||
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null }) r.DiscountAmount = eval.DiscountAmount;
|
||||
else repo.RemoveRedemption(r);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var schemeId in discountSchemeIds ?? [])
|
||||
{
|
||||
if (existing.Any(r => r.FK_SchemeId == schemeId && r.DiscountAmount.HasValue)) continue;
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||
if (eval is { Eligible: true, DiscountAmount: not null })
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_ShopPOId = shopPOId,
|
||||
DiscountAmount = eval.DiscountAmount
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var (schemeId, group) in freeBySchemes)
|
||||
{
|
||||
var freeItem = await itemRepo.GetByIdAsync(group[0].FK_ItemId)
|
||||
?? throw new InvalidOperationException("Free item not found.");
|
||||
var qty = group.Sum(l => l.Quantity);
|
||||
var redemption = existing.FirstOrDefault(r => r.FK_SchemeId == schemeId && r.FK_FreeItemId.HasValue);
|
||||
|
||||
if (redemption is null)
|
||||
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_ShopPOId = shopPOId,
|
||||
FK_FreeItemId = freeItem.Id,
|
||||
FreeQty = qty,
|
||||
FreeItemCostValue = freeItem.CostPrice ?? 0m
|
||||
});
|
||||
else
|
||||
{
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
redemption.FreeQty = qty;
|
||||
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
||||
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
||||
@@ -299,14 +620,18 @@ public class SchemeService(
|
||||
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||
|
||||
var today = DateTime.Now;
|
||||
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
|
||||
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
|
||||
return (false, "Scheme is not currently active (outside its date range).");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> allItems)
|
||||
{
|
||||
// Free-issue lines (added by a previous redemption) must not count toward a trigger/bundle
|
||||
// quantity, otherwise the free units inflate the multiplier.
|
||||
var items = allItems.Where(i => !i.IsFree && i.UnitPrice > 0).ToList();
|
||||
|
||||
switch (scheme.RuleType)
|
||||
{
|
||||
case SchemeRuleType.BuyXGetY:
|
||||
@@ -383,6 +708,9 @@ public class SchemeService(
|
||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||
|
||||
if (await repo.HasLockedInvoiceAsync(shopPOId))
|
||||
throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed.");
|
||||
|
||||
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||
var eligibility = EvaluateRule(scheme, items);
|
||||
if (!eligibility.Eligible)
|
||||
@@ -411,7 +739,10 @@ public class SchemeService(
|
||||
FK_ShopPOId = shopPOId,
|
||||
FK_ItemId = freeItem.Id,
|
||||
Quantity = (int)eligibility.FreeQty.Value,
|
||||
UnitPrice = 0m
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = scheme.Id,
|
||||
SchemeName = scheme.Name
|
||||
});
|
||||
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
@@ -422,7 +753,27 @@ public class SchemeService(
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
||||
if (newItems.Count > 0)
|
||||
{
|
||||
await shopPORepo.AddItemsAsync(newItems);
|
||||
|
||||
// A still-draft invoice was copied from the PO before this line existed, so mirror it there.
|
||||
if (shopPO.InvoiceId is Guid invoiceId &&
|
||||
await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft })
|
||||
{
|
||||
await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem
|
||||
{
|
||||
FK_InvoiceId = invoiceId,
|
||||
FK_ShopPOItemId = n.Id,
|
||||
FK_ItemId = n.FK_ItemId,
|
||||
Quantity = n.Quantity,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = n.FK_SchemeId,
|
||||
SchemeName = n.SchemeName
|
||||
}));
|
||||
}
|
||||
}
|
||||
await repo.AddRedemptionAsync(redemption);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -484,6 +835,8 @@ public class SchemeService(
|
||||
|
||||
if (freeItemId is null || freeQty is null or <= 0)
|
||||
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
||||
if (freeQty.Value != decimal.Truncate(freeQty.Value))
|
||||
throw new InvalidOperationException("FreeQty must be a whole number (order lines hold whole units).");
|
||||
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
||||
throw new InvalidOperationException("Free item not found.");
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("confirmed-without-invoice")]
|
||||
public async Task<IActionResult> GetConfirmedWithoutInvoice(
|
||||
[FromQuery] Guid? agentId,
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? shopId)
|
||||
{
|
||||
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
namespace Distribution.Features.ShopPO;
|
||||
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
||||
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
||||
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive,
|
||||
bool IsFree = false, Guid? SchemeId = null, string? SchemeName = null);
|
||||
|
||||
public record ShopPODiscountResponse(Guid SchemeId, string SchemeName, decimal Amount);
|
||||
|
||||
public record ShopPOResponse(
|
||||
Guid Id,
|
||||
@@ -34,4 +39,5 @@ public record ShopPOResponse(
|
||||
DateTime? CancelledAt = null,
|
||||
string? CancelReason = null,
|
||||
string? CancelledByName = null,
|
||||
string? CancelledByRole = null);
|
||||
string? CancelledByRole = null,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -10,16 +12,47 @@ public class ShopPOService(
|
||||
IShopPORepository repo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
SchemeService schemeService,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
||||
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
|
||||
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
|
||||
{
|
||||
if (line.IsFree)
|
||||
return new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = line.SchemeId
|
||||
};
|
||||
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
|
||||
}
|
||||
|
||||
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
|
||||
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
|
||||
notifications.NotifyAsync(
|
||||
type,
|
||||
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
|
||||
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
|
||||
ct);
|
||||
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
|
||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||
p.Items.Where(i => i.IsActive).Select(i =>
|
||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
|
||||
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
|
||||
p.IsActive, p.CreatedAt,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
||||
@@ -27,7 +60,8 @@ public class ShopPOService(
|
||||
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
||||
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
|
||||
discounts ?? []);
|
||||
|
||||
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||
{
|
||||
@@ -52,7 +86,14 @@ public class ShopPOService(
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null) return null;
|
||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
||||
return ToResponse(po, users);
|
||||
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
|
||||
}
|
||||
|
||||
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||
{
|
||||
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
||||
return items.Select(p => ToResponse(p, users)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
||||
@@ -65,20 +106,14 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
items.Add(new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
var item = await BuildLineAsync(line, request.StoreId);
|
||||
items.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||
|
||||
var po = new Models.ShopPO
|
||||
{
|
||||
FK_StoreId = request.StoreId,
|
||||
@@ -93,6 +128,7 @@ public class ShopPOService(
|
||||
await repo.AddAsync(po);
|
||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||
await repo.AddItemsAsync(items);
|
||||
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
@@ -120,27 +156,22 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
newItems.Add(new ShopPOItem
|
||||
{
|
||||
FK_ShopPOId = id,
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||
item.FK_ShopPOId = id;
|
||||
newItems.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||
|
||||
po.TotalAmount = total;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.ReplaceItemsAsync(id, newItems);
|
||||
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -168,6 +199,8 @@ public class ShopPOService(
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
|
||||
@@ -193,17 +226,25 @@ public class ShopPOService(
|
||||
CreatedBy = userId,
|
||||
TotalAmount = po.TotalAmount
|
||||
};
|
||||
// Start the invoice at the discounted total so it matches what print/finalize will recompute.
|
||||
invoice.DiscountAmount = Math.Min(await invoiceRepo.GetSchemeDiscountAsync(po.Id), po.TotalAmount);
|
||||
invoice.TotalAmount = po.TotalAmount - invoice.DiscountAmount;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await invoiceRepo.AddAsync(invoice);
|
||||
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
|
||||
await invoiceRepo.AddItemsAsync(lines);
|
||||
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
|
||||
po.InvoiceId = invoice.Id;
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
|
||||
|
||||
var saved = await repo.GetByIdAsync(id);
|
||||
return await ToResponseWithUserAsync(saved!);
|
||||
}
|
||||
@@ -231,6 +272,8 @@ public class ShopPOService(
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.StoreCredit;
|
||||
@@ -7,7 +8,7 @@ using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo)
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
private const int CreditApproverRoleLevel = 20;
|
||||
@@ -148,6 +149,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
|
||||
store.OutstandingBalance = request.NewBalance;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -160,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Only on the crossing, so repeated updates while over the limit don't re-alert.
|
||||
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.CreditLimitBreached,
|
||||
[
|
||||
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
|
||||
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
|
||||
],
|
||||
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
|
||||
ct);
|
||||
|
||||
return ToResponse(store);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -9,6 +10,7 @@ public class SupplierClaimService(
|
||||
ISupplierClaimRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IGrnRepository grnRepo,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||
@@ -149,9 +151,19 @@ public class SupplierClaimService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
|
||||
// Tells whoever raised the claim how it was decided (never the person who decided it).
|
||||
private Task NotifyDecisionAsync(string type, SupplierClaim claim, Guid decidedBy, CancellationToken ct) =>
|
||||
notifications.NotifyAsync(
|
||||
type,
|
||||
[RecipientRule.Users(claim.RaisedByUserId)],
|
||||
new NotificationArgs(null, "SupplierClaim", claim.Id, "/returns-claims/supplier-claims", decidedBy),
|
||||
ct);
|
||||
|
||||
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id)
|
||||
@@ -172,6 +184,8 @@ public class SupplierClaimService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotifications : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Notifications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Type = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Severity = table.Column<int>(type: "int", nullable: false),
|
||||
Title = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Message = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
EntityType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
EntityId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Link = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Notifications", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationRecipients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_NotificationId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_UserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
ReadAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationRecipients", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationRecipients_Notifications_FK_NotificationId",
|
||||
column: x => x.FK_NotificationId,
|
||||
principalTable: "Notifications",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationRecipients_Users_FK_UserId",
|
||||
column: x => x.FK_UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationRecipients_FK_NotificationId_FK_UserId",
|
||||
table: "NotificationRecipients",
|
||||
columns: new[] { "FK_NotificationId", "FK_UserId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationRecipients_FK_UserId_IsRead",
|
||||
table: "NotificationRecipients",
|
||||
columns: new[] { "FK_UserId", "IsRead" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notifications_EntityType_EntityId",
|
||||
table: "Notifications",
|
||||
columns: new[] { "EntityType", "EntityId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationRecipients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Notifications");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvoiceItems : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InvoiceItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InvoiceItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceItems_Invoices_FK_InvoiceId",
|
||||
column: x => x.FK_InvoiceId,
|
||||
principalTable: "Invoices",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceItems_Items_FK_ItemId",
|
||||
column: x => x.FK_ItemId,
|
||||
principalTable: "Items",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceItems_ShopPOItems_FK_ShopPOItemId",
|
||||
column: x => x.FK_ShopPOItemId,
|
||||
principalTable: "ShopPOItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InvoiceItems_FK_InvoiceId",
|
||||
table: "InvoiceItems",
|
||||
column: "FK_InvoiceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InvoiceItems_FK_ItemId",
|
||||
table: "InvoiceItems",
|
||||
column: "FK_ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InvoiceItems_FK_ShopPOItemId",
|
||||
table: "InvoiceItems",
|
||||
column: "FK_ShopPOItemId");
|
||||
|
||||
// Backfill: snapshot each existing invoice's PO lines so older invoices can be edited/finalized.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO InvoiceItems (Id, FK_InvoiceId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, CreatedAt, IsActive)
|
||||
SELECT UUID(), inv.Id, poi.Id, poi.FK_ItemId, poi.Quantity, poi.UnitPrice, NOW(6), 1
|
||||
FROM Invoices inv
|
||||
JOIN ShopPOItems poi ON poi.FK_ShopPOId = inv.FK_ShopPOId AND poi.IsActive = 1;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InvoiceItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6080
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvoiceVersionsAndLineEdits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_ShopPOItemId",
|
||||
table: "InvoiceItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)")
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsRemoved",
|
||||
table: "InvoiceItems",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InvoiceVersions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
VersionNumber = table.Column<int>(type: "int", nullable: false),
|
||||
Stage = table.Column<int>(type: "int", nullable: false),
|
||||
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InvoiceVersions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceVersions_Invoices_FK_InvoiceId",
|
||||
column: x => x.FK_InvoiceId,
|
||||
principalTable: "Invoices",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InvoiceVersionItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_InvoiceVersionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||
IsRemoved = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InvoiceVersionItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceVersionItems_InvoiceVersions_FK_InvoiceVersionId",
|
||||
column: x => x.FK_InvoiceVersionId,
|
||||
principalTable: "InvoiceVersions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceVersionItems_Items_FK_ItemId",
|
||||
column: x => x.FK_ItemId,
|
||||
principalTable: "Items",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceVersionItems_ShopPOItems_FK_ShopPOItemId",
|
||||
column: x => x.FK_ShopPOItemId,
|
||||
principalTable: "ShopPOItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InvoiceVersionItems_FK_InvoiceVersionId",
|
||||
table: "InvoiceVersionItems",
|
||||
column: "FK_InvoiceVersionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InvoiceVersionItems_FK_ItemId",
|
||||
table: "InvoiceVersionItems",
|
||||
column: "FK_ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InvoiceVersionItems_FK_ShopPOItemId",
|
||||
table: "InvoiceVersionItems",
|
||||
column: "FK_ShopPOItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InvoiceVersions_FK_InvoiceId_VersionNumber",
|
||||
table: "InvoiceVersions",
|
||||
columns: new[] { "FK_InvoiceId", "VersionNumber" },
|
||||
unique: true);
|
||||
|
||||
// Backfill: one baseline version per existing invoice from its current lines.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO InvoiceVersions (Id, FK_InvoiceId, VersionNumber, Stage, TotalAmount, CreatedBy, CreatedAt, IsActive)
|
||||
SELECT UUID(), inv.Id, 1,
|
||||
CASE inv.Status WHEN 0 THEN 0 WHEN 1 THEN 2 ELSE 3 END,
|
||||
inv.TotalAmount, inv.CreatedBy, NOW(6), 1
|
||||
FROM Invoices inv;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO InvoiceVersionItems (Id, FK_InvoiceVersionId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, IsRemoved, CreatedAt, IsActive)
|
||||
SELECT UUID(), v.Id, ii.FK_ShopPOItemId, ii.FK_ItemId, ii.Quantity, ii.UnitPrice, ii.IsRemoved, NOW(6), 1
|
||||
FROM InvoiceVersions v
|
||||
JOIN InvoiceItems ii ON ii.FK_InvoiceId = v.FK_InvoiceId AND ii.IsActive = 1
|
||||
WHERE v.VersionNumber = 1;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InvoiceVersionItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "InvoiceVersions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsRemoved",
|
||||
table: "InvoiceItems");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_ShopPOItemId",
|
||||
table: "InvoiceItems",
|
||||
type: "char(36)",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)",
|
||||
oldNullable: true)
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvoiceDiscount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "DiscountAmount",
|
||||
table: "Invoices",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Invoices",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("34500000-0000-0000-0000-000000000001"),
|
||||
column: "DiscountAmount",
|
||||
value: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Invoices",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("34500000-0000-0000-0000-000000000002"),
|
||||
column: "DiscountAmount",
|
||||
value: 0m);
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DiscountAmount",
|
||||
table: "Invoices");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFreeIssueLines : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_SchemeId",
|
||||
table: "ShopPOItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFree",
|
||||
table: "ShopPOItems",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SchemeName",
|
||||
table: "ShopPOItems",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_SchemeId",
|
||||
table: "InvoiceItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFree",
|
||||
table: "InvoiceItems",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SchemeName",
|
||||
table: "InvoiceItems",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
// Existing free-issue lines were plain zero-priced lines; flag them from their scheme redemption.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ShopPOItems i
|
||||
JOIN SchemeRedemptions r ON r.FK_ShopPOId = i.FK_ShopPOId AND r.FK_FreeItemId = i.FK_ItemId
|
||||
JOIN Schemes s ON s.Id = r.FK_SchemeId
|
||||
SET i.IsFree = 1, i.FK_SchemeId = r.FK_SchemeId, i.SchemeName = s.Name
|
||||
WHERE i.UnitPrice = 0 AND i.IsActive = 1;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE InvoiceItems ii
|
||||
JOIN ShopPOItems pi ON ii.FK_ShopPOItemId = pi.Id
|
||||
SET ii.IsFree = pi.IsFree, ii.FK_SchemeId = pi.FK_SchemeId, ii.SchemeName = pi.SchemeName
|
||||
WHERE pi.IsFree = 1;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_SchemeId",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFree",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SchemeName",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_SchemeId",
|
||||
table: "InvoiceItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFree",
|
||||
table: "InvoiceItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SchemeName",
|
||||
table: "InvoiceItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ namespace Distribution.Models
|
||||
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
||||
|
||||
public enum InvoiceStatus { Draft, Printed, Finalized }
|
||||
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
|
||||
|
||||
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||
|
||||
@@ -67,4 +68,10 @@ namespace Distribution.Models
|
||||
public enum ReceivedChequeStatusAction { Deposit, Clear, Return, Cancel }
|
||||
|
||||
public enum ReceivedFromType { Customer, Supplier, Other }
|
||||
|
||||
public enum ExpenseStatus { Draft, Posted, Paid, Void }
|
||||
|
||||
public enum ExpenseAction { Draft, Post, Pay }
|
||||
|
||||
public enum RecurringFrequency { Weekly, Monthly, Quarterly, Yearly }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class Expense : Template
|
||||
{
|
||||
/// <summary>Leaf category (Title). Parent is the user-facing Category group.</summary>
|
||||
public Guid FK_CategoryId { get; set; }
|
||||
[ForeignKey("FK_CategoryId")]
|
||||
public ExpenseCategory? Category { get; set; }
|
||||
|
||||
/// <summary>Branch the expense is attributed to. Null = company-wide.</summary>
|
||||
public Guid? FK_WarehouseId { get; set; }
|
||||
[ForeignKey("FK_WarehouseId")]
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
[Precision(18, 2)]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime BillDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
/// <summary>First day of the month the expense covers (YYYY-MM-01).</summary>
|
||||
public DateTime? PeriodCovered { get; set; }
|
||||
|
||||
public Guid? FK_VendorId { get; set; }
|
||||
[ForeignKey("FK_VendorId")]
|
||||
public ExpenseVendor? Vendor { get; set; }
|
||||
|
||||
public string? Payee { get; set; }
|
||||
public string? BillRef { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
|
||||
|
||||
/// <summary>Payment settlement — null until Status transitions to Paid.</summary>
|
||||
public PaymentMethod? Method { get; set; }
|
||||
public Guid? FK_BankAccountId { get; set; }
|
||||
[ForeignKey("FK_BankAccountId")]
|
||||
public BankAccount? BankAccount { get; set; }
|
||||
|
||||
public Guid? FK_ChequePageId { get; set; }
|
||||
[ForeignKey("FK_ChequePageId")]
|
||||
public ChequePage? ChequePage { get; set; }
|
||||
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
public Guid RecordedByUserId { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PaidByUserId { get; set; }
|
||||
public Guid? VoidedByUserId { get; set; }
|
||||
public DateTime? VoidedAt { get; set; }
|
||||
public string? VoidReason { get; set; }
|
||||
|
||||
/// <summary>Null for manually-entered expenses; set when generated by run-due.</summary>
|
||||
public Guid? FK_RecurringTemplateId { get; set; }
|
||||
[ForeignKey("FK_RecurringTemplateId")]
|
||||
public RecurringExpenseTemplate? RecurringTemplate { get; set; }
|
||||
|
||||
public ICollection<ExpenseAttachment> Attachments { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class ExpenseAttachment : Template
|
||||
{
|
||||
public Guid FK_ExpenseId { get; set; }
|
||||
[ForeignKey("FK_ExpenseId")]
|
||||
public Expense? Expense { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Absolute path on disk. Served through a streaming endpoint, never statically.</summary>
|
||||
public string StoredPath { get; set; } = string.Empty;
|
||||
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
public Guid UploadedByUserId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Two-level tree: top-level categories (e.g. "Salaries", "Utilities") and
|
||||
/// child "titles" (e.g. "Bonus", "Electricity"). Expenses link to a leaf (Title).
|
||||
/// </summary>
|
||||
public class ExpenseCategory : Template
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Code { get; set; }
|
||||
|
||||
public Guid? FK_ParentCategoryId { get; set; }
|
||||
[ForeignKey("FK_ParentCategoryId")]
|
||||
public ExpenseCategory? Parent { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight vendor directory used only by the Expenses module. Kept
|
||||
/// separate from Supplier (which has performance scores, PO/GRN linkage).
|
||||
/// </summary>
|
||||
public class ExpenseVendor : Template
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? ContactInfo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,13 @@ namespace Distribution.Models
|
||||
public DateTime? FinalizedAt { get; set; }
|
||||
public Guid? FinalizedBy { get; set; }
|
||||
|
||||
/// <summary>Scheme discount redeemed against the PO, subtracted from the billable lines to give TotalAmount.</summary>
|
||||
public decimal DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>Payable amount: billable lines minus DiscountAmount.</summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
|
||||
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class InvoiceItem : Template
|
||||
{
|
||||
public Guid FK_InvoiceId { get; set; }
|
||||
[ForeignKey("FK_InvoiceId")]
|
||||
public Invoice Invoice { get; set; }
|
||||
|
||||
public Guid? FK_ShopPOItemId { get; set; }
|
||||
[ForeignKey("FK_ShopPOItemId")]
|
||||
public ShopPOItem? ShopPOItem { get; set; }
|
||||
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IsRemoved { get; set; }
|
||||
|
||||
/// <summary>Free issue copied from the Shop PO line (UnitPrice 0).</summary>
|
||||
public bool IsFree { get; set; }
|
||||
public Guid? FK_SchemeId { get; set; }
|
||||
public string? SchemeName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
// Immutable snapshot of an invoice's lines at a point in its lifecycle.
|
||||
public class InvoiceVersion : Template
|
||||
{
|
||||
public Guid FK_InvoiceId { get; set; }
|
||||
[ForeignKey("FK_InvoiceId")]
|
||||
public Invoice Invoice { get; set; }
|
||||
|
||||
public int VersionNumber { get; set; }
|
||||
public InvoiceVersionStage Stage { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
|
||||
public ICollection<InvoiceVersionItem> Items { get; set; } = new List<InvoiceVersionItem>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class InvoiceVersionItem : Template
|
||||
{
|
||||
public Guid FK_InvoiceVersionId { get; set; }
|
||||
[ForeignKey("FK_InvoiceVersionId")]
|
||||
public InvoiceVersion InvoiceVersion { get; set; }
|
||||
|
||||
public Guid? FK_ShopPOItemId { get; set; }
|
||||
[ForeignKey("FK_ShopPOItemId")]
|
||||
public ShopPOItem? ShopPOItem { get; set; }
|
||||
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IsRemoved { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public enum NotificationSeverity { Info, Success, Warning, Critical }
|
||||
|
||||
/// <summary>One row per event. Who it was delivered to lives in <see cref="NotificationRecipient"/>.</summary>
|
||||
public class Notification : Template
|
||||
{
|
||||
[MaxLength(60)]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public NotificationSeverity Severity { get; set; }
|
||||
|
||||
[MaxLength(150)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(50)]
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
|
||||
public Guid EntityId { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string Link { get; set; } = string.Empty;
|
||||
|
||||
public Guid? CreatedByUserId { get; set; }
|
||||
|
||||
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>One row per user per notification, so read state is per user.</summary>
|
||||
public class NotificationRecipient : Template
|
||||
{
|
||||
public Guid FK_NotificationId { get; set; }
|
||||
[ForeignKey("FK_NotificationId")]
|
||||
public Notification Notification { get; set; }
|
||||
|
||||
public Guid FK_UserId { get; set; }
|
||||
[ForeignKey("FK_UserId")]
|
||||
public User User { get; set; }
|
||||
|
||||
public bool IsRead { get; set; }
|
||||
public DateTime? ReadAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Blueprint for a recurring expense. Materialized into Expense rows by
|
||||
/// POST /api/recurring-expenses/run-due based on NextRunDate.
|
||||
/// </summary>
|
||||
public class RecurringExpenseTemplate : Template
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public Guid FK_CategoryId { get; set; }
|
||||
[ForeignKey("FK_CategoryId")]
|
||||
public ExpenseCategory? Category { get; set; }
|
||||
|
||||
public Guid? FK_WarehouseId { get; set; }
|
||||
[ForeignKey("FK_WarehouseId")]
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public Guid? FK_VendorId { get; set; }
|
||||
[ForeignKey("FK_VendorId")]
|
||||
public ExpenseVendor? Vendor { get; set; }
|
||||
|
||||
[Precision(18, 2)]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public string? Payee { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public RecurringFrequency Frequency { get; set; }
|
||||
/// <summary>1..31 for Monthly/Quarterly/Yearly. Ignored for Weekly.</summary>
|
||||
public int? DayOfMonth { get; set; }
|
||||
/// <summary>0..6 (Sun=0) for Weekly. Ignored otherwise.</summary>
|
||||
public int? DayOfWeek { get; set; }
|
||||
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public DateTime NextRunDate { get; set; }
|
||||
|
||||
/// <summary>When true, generated expenses jump straight to Posted; otherwise Draft.</summary>
|
||||
public bool AutoPost { get; set; }
|
||||
|
||||
public bool IsPaused { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,11 @@ namespace Distribution.Models
|
||||
public int Quantity { get; set; }
|
||||
public int QuantityFulfilled { get; set; } = 0;
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>Free issue granted by a scheme (UnitPrice 0); shown in the Free Issue column.</summary>
|
||||
public bool IsFree { get; set; }
|
||||
public Guid? FK_SchemeId { get; set; }
|
||||
/// <summary>Scheme name when it was applied, so later scheme edits don't rewrite history.</summary>
|
||||
public string? SchemeName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Features.DriverProfile;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Invoice;
|
||||
@@ -30,6 +31,10 @@ using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Store;
|
||||
using Distribution.Features.Supplier;
|
||||
using Distribution.Features.SupplierPayment;
|
||||
using Distribution.Features.Expense;
|
||||
using Distribution.Features.ExpenseCategory;
|
||||
using Distribution.Features.ExpenseVendor;
|
||||
using Distribution.Features.RecurringExpense;
|
||||
using Distribution.Features.Sync;
|
||||
using Distribution.Features.Uom;
|
||||
using Distribution.Features.User;
|
||||
@@ -78,6 +83,8 @@ builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
||||
builder.Services.AddScoped<ProductReportService>();
|
||||
builder.Services.AddScoped<IDashboardReportRepository, DashboardReportRepository>();
|
||||
builder.Services.AddScoped<DashboardReportService>();
|
||||
builder.Services.AddScoped<Distribution.Features.PaymentReport.IPaymentReportRepository, Distribution.Features.PaymentReport.PaymentReportRepository>();
|
||||
builder.Services.AddScoped<Distribution.Features.PaymentReport.PaymentReportService>();
|
||||
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
|
||||
builder.Services.AddScoped<StockReportService>();
|
||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||
@@ -100,6 +107,10 @@ builder.Services.AddScoped<IBankAccountRepository, BankAccountRepository>();
|
||||
builder.Services.AddScoped<IChequeBookRepository, ChequeBookRepository>();
|
||||
builder.Services.AddScoped<IReceivedChequeRepository, ReceivedChequeRepository>();
|
||||
builder.Services.AddScoped<ISupplierPaymentRepository, SupplierPaymentRepository>();
|
||||
builder.Services.AddScoped<IExpenseCategoryRepository, ExpenseCategoryRepository>();
|
||||
builder.Services.AddScoped<IExpenseVendorRepository, ExpenseVendorRepository>();
|
||||
builder.Services.AddScoped<IExpenseRepository, ExpenseRepository>();
|
||||
builder.Services.AddScoped<IRecurringExpenseRepository, RecurringExpenseRepository>();
|
||||
builder.Services.AddScoped<Distribution.Features.StoreCredit.IStoreCreditMovementRepository, Distribution.Features.StoreCredit.StoreCreditMovementRepository>();
|
||||
|
||||
builder.Services.AddScoped<RegionService>();
|
||||
@@ -140,8 +151,22 @@ builder.Services.AddScoped<BankAccountService>();
|
||||
builder.Services.AddScoped<ChequeBookService>();
|
||||
builder.Services.AddScoped<ReceivedChequeService>();
|
||||
builder.Services.AddScoped<SupplierPaymentService>();
|
||||
builder.Services.AddScoped<ExpenseCategoryService>();
|
||||
builder.Services.AddScoped<ExpenseVendorService>();
|
||||
builder.Services.AddScoped<ExpenseService>();
|
||||
builder.Services.AddScoped<ExpenseAttachmentService>();
|
||||
builder.Services.AddScoped<RecurringExpenseService>();
|
||||
builder.Services.AddScoped<SysConfigService>();
|
||||
|
||||
builder.Services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
builder.Services.AddScoped<NotificationRecipientResolver>();
|
||||
builder.Services.AddScoped<NotificationService>();
|
||||
builder.Services.AddScoped<INotificationService>(sp => sp.GetRequiredService<NotificationService>());
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.SignalR.IUserIdProvider, NotificationUserIdProvider>();
|
||||
builder.Services.AddHostedService<NotificationCleanupJob>();
|
||||
builder.Services.AddSignalR()
|
||||
.AddJsonProtocol(options => options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
@@ -193,5 +218,6 @@ app.UseMiddleware<AuthMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHub<NotificationHub>(NotificationHub.Path);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Expense": {
|
||||
"AttachmentsRoot": "/home/distribution/core/assets/expenses"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@
|
||||
"StoreManagement": {
|
||||
"UnverifiedKycOrderThreshold": 0
|
||||
},
|
||||
"Expense": {
|
||||
// Dev default. Production overrides to /home/distribution/core/assets/expenses
|
||||
// via appsettings.Production.json or an ENV var.
|
||||
"AttachmentsRoot": "D:\\erp-storage\\expenses"
|
||||
},
|
||||
"AuthService": {
|
||||
"BaseUrl": ""
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ order must contain every component in at least its listed `Qty` before the schem
|
||||
SchemeRedemption
|
||||
├── Id (Guid) — PK
|
||||
├── FK_SchemeId (Guid) — required
|
||||
├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below)
|
||||
├── FK_ShopPOId (Guid?) — nullable (see FieldInvoiceId below); the Shop PO the scheme was applied to
|
||||
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
|
||||
│ entity exists yet, so this is an unconstrained Guid for now
|
||||
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
||||
@@ -87,8 +87,8 @@ SchemeRedemption
|
||||
│ later changes
|
||||
├── RedeemedAt (DateTime)
|
||||
```
|
||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
|
||||
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`ShopPO` restrict-delete
|
||||
(a scheme or Shop PO with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||
|
||||
### Item (extended)
|
||||
```
|
||||
@@ -124,8 +124,10 @@ columns/child table after the first migration had already been applied to the de
|
||||
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
|
||||
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
|
||||
| — | `DELETE /api/schemes/{id}/bundle-components/{itemId}` | Removes one FreeBundle required-item row |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
|
||||
| — | `POST /api/schemes/suggest` | Body `{ storeId, items:[{itemId, quantity, unitPrice?}] }`; scheme suggestions for a Shop PO that is not saved yet |
|
||||
| `evaluateSchemeEligibility(storeId, shopPOId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` (`orderId` is the Shop PO id) | Returns eligibility for every in-scope scheme, not just one |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts |
|
||||
| — | `POST /api/invoices/{id}/preview-totals` | Draft-editor preview of discount, total and free entitlement for unsaved lines |
|
||||
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
|
||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
|
||||
|
||||
@@ -216,13 +218,18 @@ Use this to add/edit one bundle component after creation.
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`.
|
||||
(Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount
|
||||
ceiling isn't implemented in code yet; this is recorded as the target behavior.)
|
||||
2. `evaluateSchemeEligibility` matches a scheme to a store/order only if: the scheme's `RegionId` is
|
||||
2. `evaluateSchemeEligibility` matches a scheme to a store/Shop PO only if: the scheme's `RegionId` is
|
||||
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
|
||||
order's date falls within `[DateRangeFrom, DateRangeTo]`.
|
||||
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
|
||||
Shop PO's date falls within `[DateRangeFrom, DateRangeTo]`. `DateRangeTo` is inclusive of the
|
||||
whole calendar day (compared by date, so a scheme ending `2026-08-31` still applies on the 31st).
|
||||
3. `BuyXGetY` eligibility: the Shop PO must contain at least `TriggerQty` of `TriggerItem`; the payout
|
||||
scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`).
|
||||
4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
|
||||
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
||||
Only **paid** lines (`UnitPrice > 0`) count toward `boughtQty` — free lines added by an earlier
|
||||
redemption never count toward a trigger, so "buy 10 get 1 of the same item" can't self-inflate.
|
||||
4. `FreeBundle` eligibility: the Shop PO must contain every `SchemeBundleComponent` in at least its
|
||||
listed `Qty` (paid lines only, same as rule 3); payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
||||
`FreeQty` must be a whole number (validated on create / `PUT .../free-goods-rule`), because
|
||||
Shop PO lines hold whole units.
|
||||
5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`,
|
||||
`Quantity = FreeQty`), rather than only recording a discount amount — the free line still needs to
|
||||
be picked, delivered, and stock-tracked like any other sold line.
|
||||
@@ -240,6 +247,80 @@ Use this to add/edit one bundle component after creation.
|
||||
|
||||
---
|
||||
|
||||
## Offers on the PO and Invoice (implemented)
|
||||
|
||||
Nothing is added silently: the system **suggests**, the user **accepts**, and what was accepted stays **editable**.
|
||||
|
||||
### 1. On the Shop PO (new and edit screens)
|
||||
1. While paid lines are entered, the screen calls `POST /api/schemes/suggest` (`{ storeId, items:[{itemId, quantity, unitPrice?}] }`).
|
||||
The server returns the in-scope schemes (region, segment, date) the lines qualify for, plus Buy & get free /
|
||||
Combo gift schemes the cart is part-way to (`reason` doubles as a nudge, e.g. "Requires 10 of the trigger item;
|
||||
ShopPO has 6").
|
||||
2. The **Offers & Free Issues** panel shows:
|
||||
- **Free goods** (BuyXGetY / FreeBundle): "Qualifies for N free X" with **Add free**. The accepted free line is
|
||||
editable (qty, remove) and is saved as a line with `isFree = true` and the `schemeId`.
|
||||
- **Percentage discount** (FlatPercent / SlabDiscount): "Saves Rs. X" with **Apply discount**. Only one discount
|
||||
per order. The accepted scheme ids are sent as `discountSchemeIds` on create / update-items.
|
||||
3. If a later paid-qty change shrinks what the order earns, the screen trims the free qty / drops the discount and
|
||||
says so. The server enforces the same rules on save: free qty within the entitlement, free item in stock, scheme
|
||||
active and in scope, at most one discount, order still qualifies.
|
||||
4. Saving keeps `SchemeRedemption` in step with the lines: created or resized per free line, removed with its line,
|
||||
and discount redemptions created, recomputed, or dropped if the order stops qualifying.
|
||||
5. `GET /api/shop-pos/{id}` returns `discounts[]` (scheme, amount) so the edit screen starts with the right state and
|
||||
the PO view shows a "Scheme discount" row above the total. `PO.TotalAmount` stays the full lines total.
|
||||
|
||||
### 2. On the invoice
|
||||
- The invoice copies the PO lines, including free lines (`IsFree`, `FK_SchemeId`, `SchemeName`). Free lines show in a
|
||||
**Free Issue** column (a "-" in the cell that does not apply), are listed **after all paid lines**, are zero-priced
|
||||
(price cannot be edited) and can be lowered like any line while the invoice is Draft.
|
||||
- `Invoice.TotalAmount = billable lines - DiscountAmount`. When the invoice is created from a PO it starts at the
|
||||
discounted total.
|
||||
- **Draft invoice editor** (`/sales/invoices/{id}/edit`):
|
||||
- A **Scheme discount** box lists the flat / slab discounts the current lines qualify for with **Apply discount** /
|
||||
**Remove**. This also covers a PO that was confirmed before a discount was applied. `PUT /api/invoices/{id}/items`
|
||||
accepts `discountSchemeIds` (null = leave as is).
|
||||
- `POST /api/invoices/{id}/preview-totals` (`{ items:[{itemId, quantity, unitPrice, isFree, isRemoved, schemeId}], discountSchemeIds? }`)
|
||||
returns the discount, total and per-scheme free entitlement for the unsaved lines; the editor calls it (debounced)
|
||||
so the Scheme discount row and Total update while typing. It changes nothing.
|
||||
- **Recalculation.** On every line change (add, remove, restore, re-price, quantity) and again at print and finalize,
|
||||
`InvoiceService.ApplyTotalsAsync` first reconciles free lines, then re-evaluates the discount against the current lines:
|
||||
- FlatPercent stays the set % of the current lines total; SlabDiscount can move up or down a level, or drop to 0 and
|
||||
return if the lines requalify. Free lines and struck-out lines are ignored. The redemption's `DiscountAmount` is
|
||||
updated with it, so scheme spend / ROI stay correct.
|
||||
- **Free lines only shrink automatically.** If the paid quantity that earned a free issue is lowered or removed, the free
|
||||
quantity is reduced to what is still earned (a line that earns nothing is struck out, keeping its quantity so it can be
|
||||
restored) and the PO's free-goods redemption is resized. Free quantity is never raised automatically; a person raises it,
|
||||
up to what the scheme earns.
|
||||
- The invoice page's Items table ends with **Subtotal / Scheme discount / Total**, and the printed A4 and receipt copies show
|
||||
Subtotal, Scheme discount, Total (Paid / Balance Due use the discounted total).
|
||||
- The older `POST /api/orders/{id}/apply-scheme` endpoint still works for an already-saved PO and flags the line the same way
|
||||
(and mirrors a free line onto a still-Draft invoice).
|
||||
|
||||
### 3. Scheme setup screens
|
||||
`/pricing/schemes/new`, `/pricing/schemes/{id}` (read-only) and `/pricing/schemes/{id}/edit` are full pages (no modal) built on one
|
||||
form. Rule types are shown in plain words: **Buy & get free** (BuyXGetY), **Combo gift** (FreeBundle), **Flat discount**
|
||||
(FlatPercent), **Spend more, save more** (SlabDiscount); the fields adapt to the type (product dropdowns, quantities, % and
|
||||
levels) with a live summary sentence. Required fields come first; Active, then the optional Region / Store segment last. The scheme
|
||||
name is optional (generated from the offer when blank). The rule type cannot change after creation.
|
||||
|
||||
### Data
|
||||
`ShopPOItem` and `InvoiceItem` gained `IsFree`, `FK_SchemeId` (plain Guid, no FK constraint) and a `SchemeName` snapshot. Migration
|
||||
`AddFreeIssueLines` also back-fills existing zero-priced redemption lines.
|
||||
|
||||
### Not done yet
|
||||
`SchemeRedemption.FK_InvoiceId` (`FieldInvoiceId` is still an unused placeholder); invoice-version snapshots do not record `IsFree`;
|
||||
no hint or one-click "add more free units" when the paid quantity rises; scope (date / region) is checked when a discount is first
|
||||
applied, not re-checked on later invoice edits; Slab "close to the next level" hints are not shown.
|
||||
|
||||
## Known Gaps vs. Enterprise Practice
|
||||
|
||||
Scheme code, lifecycle (Draft/Approved/Active/Expired), priority and stackable/exclusive flags;
|
||||
budget and per-store caps; triggers on product groups/brands/categories or order value; tiered
|
||||
free-goods slabs; funding source (company vs supplier — ties into SupplierClaims and GRN
|
||||
`FreeQuantity`); stock reservation for the free item (the current check only sums balances).
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
|
||||
|
||||
@@ -1,67 +1,330 @@
|
||||
# 21 — Notifications & Alerts
|
||||
|
||||
**Status**: Not Started
|
||||
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
|
||||
**Status**: v1 in-app built — backend, SignalR hub, frontend bell + view-all page, 10 unit tests. EF migration `AddNotifications` is generated but **not applied** to any database. Six of the seven Phase 1 triggers are wired into the services (see **Triggers**); `StockLow` is deferred. The temporary test endpoint and button were removed.
|
||||
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
|
||||
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
|
||||
produce.
|
||||
Real-time **in-app** notifications (bell + toast) triggered by events other modules already produce
|
||||
([mainPlan.md §15](./mainPlan.md#15-notifications--alerts)). Email / SMS / push are out of scope for v1.
|
||||
|
||||
Real-time uses **SignalR**. A notification is **saved first, pushed second**, so offline users see
|
||||
what they missed on their next load.
|
||||
|
||||
---
|
||||
|
||||
## Background / Context
|
||||
## Implemented now — who gets which notification
|
||||
|
||||
No notification channel exists in this codebase today. This step is purely the delivery mechanism
|
||||
and trigger wiring; the underlying conditions it watches for are already defined elsewhere
|
||||
(reorder point in [02_stock_ledger.md](./02_stock_ledger.md), credit limit in
|
||||
[12_store_management.md](./12_store_management.md), route deviation logic in
|
||||
[09_fraud_controls.md](./09_fraud_controls.md)).
|
||||
Every notification is **in-app only** (bell + toast + `/notifications`), pushed live over SignalR.
|
||||
|
||||
| # | When this happens | Where in the app | Notification the user sees | Who receives it | Who does **not** | Level | Click opens |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | Shop order **confirmed** | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` | **Shop order confirmed** — "Shop order for *Store* was confirmed." | The user who raised the order + that user's supervisor | The person who confirmed it | Info | `/sales/orders` |
|
||||
| 2 | Shop order **cancelled** | `ShopPOService.CancelAsync` | **Shop order cancelled** — "Shop order for *Store* was cancelled: *reason*" | The user who raised the order + that user's supervisor | The person who cancelled it | Warning | `/sales/orders` |
|
||||
| 3 | Supplier claim **approved** | `SupplierClaimService.ApproveAsync` | **Supplier claim approved** | The user who raised the claim | The approver | Success | `/returns-claims/supplier-claims` |
|
||||
| 4 | Supplier claim **rejected** | `SupplierClaimService.RejectAsync` | **Supplier claim rejected** | The user who raised the claim | The person who rejected it | Warning | `/returns-claims/supplier-claims` |
|
||||
| 5 | **Driver assigned** to a ride | `RideService.AssignDriverAsync` | **Ride assigned** — "You have been assigned to a ride." | The assigned driver | Everyone else | Info | `/operations/rides` |
|
||||
| 6 | Ride **cancelled** | `RideService.CancelAsync` | **Ride cancelled** — "A ride was cancelled (*type*). *reason*" | The owner of every route in the ride, plus users with `delivery-task.manage` whose scope covers those routes | The person who cancelled it | Critical | `/operations/rides` |
|
||||
| 7 | Delivery **failed** | `DeliveryTaskService.FailAsync` | **Delivery failed** — "Delivery to *Store* failed. *note*" | The owner of the store's route, plus users with `delivery-task.manage` whose scope covers the store | Nobody is excluded (this action does not record who did it) | Warning | `/operations/deliveries` |
|
||||
| 8 | Delivery **partly delivered** | `DeliveryTaskService.ConfirmAsync` | **Partial delivery** — "Delivery to *Store* was only partly delivered." | Same as #7 | The person who confirmed it | Warning | `/operations/deliveries` |
|
||||
| 9 | Store **credit limit crossed** | `StoreService.UpdateOutstandingBalanceAsync` | **Credit limit exceeded** — "*Store* has exceeded its credit limit." | The owner of the store's route, plus users with `store.manage` whose scope covers the store | The person who updated the balance. Fires only when the balance goes from within the limit to above it | Warning | `/stores/{id}` |
|
||||
|
||||
**Not wired yet:** low stock (`StockLow`), cheque returned / bounced / due, expiring batches, overdue payments, and the Phase 2 list below.
|
||||
|
||||
### Three rules that decide who sees what
|
||||
|
||||
1. **You are never notified of your own action.**
|
||||
2. **Receiving needs no permission** — any logged-in user gets the bell and the page. Permissions only matter for *doing* the action, and for the "managers in scope" recipients (they need the permission **and** a scope covering the store or route).
|
||||
3. **SuperAdmin usually acts, rarely receives.** It holds every permission but has no scope rows in the seed, so it is only a recipient when it created the record (orders, claims) or owns the route.
|
||||
|
||||
### With two logins — SuperAdmin and a sales rep
|
||||
|
||||
| Who does it | Action | Who gets the notification |
|
||||
|---|---|---|
|
||||
| Rep creates, **SuperAdmin** confirms or cancels | Shop order | **Rep** (creator), plus the rep's supervisor if they have one |
|
||||
| **SuperAdmin** creates, rep confirms or cancels | Shop order | **SuperAdmin** (creator) |
|
||||
| SuperAdmin | Sets a store's outstanding balance above its limit | The **rep**, if the rep owns that store's route. SuperAdmin never (actor) |
|
||||
| SuperAdmin | Assigns a driver to a ride | The **rep**, only if the rep is that driver |
|
||||
| SuperAdmin | Approves or rejects a supplier claim | Whoever raised the claim — the **rep** only if the rep raised it |
|
||||
| Anyone | Fails a delivery / cancels a ride | The **rep**, if the rep owns a route involved. SuperAdmin only if it has a scope on that route |
|
||||
|
||||
Seed route owners: `nimal.perera` owns the Colombo and Negombo routes; `kamal.silva` owns Kandy and Matale.
|
||||
|
||||
### Quick test (two browsers, one login each, both on a dashboard page)
|
||||
|
||||
1. As the **rep**, create a shop order (Sales → New).
|
||||
2. As **SuperAdmin**, confirm it, then create a second one and cancel it with a reason.
|
||||
3. The rep sees a toast, a red badge on the bell and a new row on `/notifications` — "Shop order confirmed" and "Shop order cancelled: *reason*". Clicking one marks it read and opens `/sales/orders`.
|
||||
4. Reverse it: SuperAdmin creates an order and the rep confirms or cancels it — SuperAdmin is notified.
|
||||
5. Credit limit: there is no screen for it, so use Swagger (`/swagger`): `PUT /api/stores/{id}/outstanding-balance` with `{ "newBalance": 60000 }` on a store whose limit is lower (Fort Mini Mart: limit 50,000). A second call with a higher value does not alert again; drop the balance back under the limit and set it over again to re-trigger.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
## Flow
|
||||
|
||||
### Notification
|
||||
|
||||
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
|
||||
`ReadAt` (nullable).
|
||||
```
|
||||
Service action ──► uow.CommitAsync() (business transaction, unchanged)
|
||||
│
|
||||
▼ only after a successful commit
|
||||
NotificationService.NotifyAsync(type, recipientRule, args, entity)
|
||||
1. resolve recipients (users | permission | scope | reports-to)
|
||||
2. insert Notification + NotificationRecipient rows
|
||||
3. hub.Clients.Users(ids).SendAsync("notification", dto)
|
||||
▼
|
||||
Frontend provider: connection.on("notification") → badge + toast → click opens Link + marks read
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
## Decisions
|
||||
|
||||
| Function reference | Endpoint intent |
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) |
|
||||
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
|
||||
| `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) |
|
||||
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. |
|
||||
| — | `GET /api/notifications` — recipient's own notification inbox |
|
||||
| — | `PUT /api/notifications/{id}/read` |
|
||||
| First batch of triggers | The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed |
|
||||
| Channels | In-app only (SignalR). Email / SMS / push deferred |
|
||||
| Per-type config table + admin screen | **Not in v1.** Every type is always enabled; add `NotificationModuleConfig` when someone needs to mute a type |
|
||||
| Scheduled alerts (Phase 1b) | Second pass. Only the retention cleanup job ships in v1 |
|
||||
| CORS / instances | Existing `Cors:AllowedOrigins` + `AllowCredentials()` already suit SignalR. Single API instance, no Redis backplane |
|
||||
| Recipients | By **permission code** and scope, not role name. The seeded roles are `SalesRep`, `Supervisor`, `AreaManager`, `AreaSupervisor`, `RegionalManager`, `RegionalAgency`, `SuperAdmin`; there is no Accounts / operations role |
|
||||
| Ids | `User`, `Permission` and entity ids are **Guid** (see seeds) |
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
## Data Model
|
||||
|
||||
- Every `send*` function in §15 is a trigger, not a user-facing endpoint — the only client-facing
|
||||
endpoints are inbox read/list.
|
||||
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
|
||||
transaction that triggered them — fire-and-forget, logged separately.
|
||||
**Notification** — one row per event
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Id` | Guid PK | |
|
||||
| `Type` | varchar(60) | `NotificationTypes` constant; frontend maps it to an icon |
|
||||
| `Severity` | enum | `Info`, `Success`, `Warning`, `Critical` |
|
||||
| `Title` / `Message` | varchar(150) / varchar(500) | Built from the type's templates |
|
||||
| `EntityType` / `EntityId` | varchar(50) / Guid | The related record |
|
||||
| `Link` | varchar(200) | Frontend route, e.g. `/payments/received-cheques/88` |
|
||||
| `CreatedByUserId` | FK User, null | Null for system / scheduled events |
|
||||
| `CreatedAt` | datetime | `DateTime.Now` |
|
||||
| `DedupeKey` | — | **Not in v1.** Added with the Phase 1b scheduled jobs, e.g. `ItemBatchExpiring:77:2026-10-20` |
|
||||
|
||||
**NotificationRecipient** — one row per user per event (read state is per user)
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `Id` | Guid PK |
|
||||
| `NotificationId` | FK |
|
||||
| `UserId` | FK |
|
||||
| `IsRead` / `ReadAt` | bool / datetime null |
|
||||
|
||||
Index `(UserId, IsRead)`; the bell orders by the joined `Notification.CreatedAt DESC`.
|
||||
|
||||
No config table in v1 — every type is enabled. (Later: `NotificationModuleConfig` with `Type` PK +
|
||||
`InAppEnabled`, missing row = enabled.)
|
||||
|
||||
Example (failed delivery):
|
||||
```
|
||||
Notification: Type=DeliveryTaskFailed, Severity=Warning,
|
||||
Title="Delivery failed", Message="Delivery to Fort Mini Mart failed.",
|
||||
EntityType=DeliveryTask, EntityId=<guid>, Link=/operations/delivery-tasks/<guid>
|
||||
NotificationRecipient: (that notification, Nimal Perera - route owner, unread)
|
||||
(that notification, a delivery-task.manage user in Western region, unread)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipients
|
||||
|
||||
Every trigger names its rule. There is **no "notify everyone"** option.
|
||||
|
||||
| Rule | Resolves to |
|
||||
|---|---|
|
||||
| `Users(ids)` | Those users (creator, driver, approver, claim owner) |
|
||||
| `Permission(code)` | Active users whose role has that permission code (`User.FK_RoleId` → `RolePermission` → `Permission.Code`), e.g. `delivery-task.manage` |
|
||||
| `Scope(kind, id)` | Users whose `UserStoreScope` / `UserRegionScope` / `UserRouteScope` covers the record |
|
||||
| `PermissionInScope(code, kind, id)` | Intersection of the two (e.g. `store.manage` users for that store's region) |
|
||||
| `ReportsTo(userId)` | The user's supervisor via `User.FK_ReportsToUserId` |
|
||||
| `RouteOwner(kind, id)` | The owner (`Route.FK_OwnerUserId`) of the route, or of the store's route when `kind` is `Store` |
|
||||
|
||||
`SuperAdmin` holds every permission, so it receives `Permission` rules automatically; use
|
||||
`PermissionInScope` where that would be too noisy.
|
||||
|
||||
The resolver always drops inactive users and the acting user, and de-duplicates.
|
||||
|
||||
---
|
||||
|
||||
## Triggers
|
||||
|
||||
Each is called **after** the owning service's `uow.CommitAsync()`.
|
||||
|
||||
**Phase 1 — must have**
|
||||
|
||||
| Type | Raised by | When | Recipients | Severity |
|
||||
|---|---|---|---|---|
|
||||
| Type | Status | Raised by | When | Recipients | Severity |
|
||||
|---|---|---|---|---|---|
|
||||
| `SupplierClaimApproved` / `Rejected` | ✅ wired | `SupplierClaimService.ApproveAsync` / `RejectAsync` | Decision | `Users(RaisedByUserId)` (not the decider) | Success / Warning |
|
||||
| `RideCancelled` | ✅ wired | `RideService.CancelAsync` | Cancelled (message carries `CancelRequestType` + reason) | For each distinct route in the ride's delivery tasks: `RouteOwner(Route)` + `PermissionInScope(delivery-task.manage, Route)`. A ride has no route of its own, so routes come from its tasks' stores | Critical |
|
||||
| `RideDriverAssigned` | ✅ wired | `RideService.AssignDriverAsync` | Driver assigned | `Users(driver.FK_UserId)` | Info |
|
||||
| `DeliveryTaskFailed` | ✅ wired | `DeliveryTaskService.FailAsync` | Task failed | `RouteOwner(Store)` + `PermissionInScope(delivery-task.manage, Store)` | Warning |
|
||||
| `DeliveryTaskPartiallyDelivered` | ✅ wired | `DeliveryTaskService.ConfirmAsync` | Confirmed with `fullyDelivered == false` (a separate type: "failed" would be the wrong wording) | same as above | Warning |
|
||||
| `ShopPOConfirmed` / `Cancelled` | ✅ wired | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` / `CancelAsync` | Status change | `Users(RaisedById)` + `ReportsTo(RaisedById)` (not the actor) | Info / Warning |
|
||||
| `CreditLimitBreached` | ✅ wired | `StoreService.UpdateOutstandingBalanceAsync` | Balance crosses from ≤ to > `CreditLimit` (crossing only). This is the only place `OutstandingBalance` is ever written — invoices and payments do not update it | `RouteOwner(Store)` + `PermissionInScope(store.manage, Store)` | Warning |
|
||||
| `StockLow` | ⏸ deferred | Stock movement | Crosses to ≤ `Item.ReorderLevel` | Undecided — warehouses have no region / scope link, so `PermissionInScope` cannot target them. Needs a per-request tracker + action filter because stock moves from many call sites that commit separately | Warning |
|
||||
|
||||
**Waiting on finance permissions** (not in the seed yet — add a cheque permission first)
|
||||
|
||||
`ReceivedChequeReturned` (`ReceivedChequeService.UpdateStatusAsync`, action `Return`, Critical) ·
|
||||
`ChequePageBounced` (`ChequeBookService.UpdatePageStatusAsync`, action `Bounce`, Critical)
|
||||
|
||||
**Phase 1b — scheduled, second pass** (daily `BackgroundService`, uses `DedupeKey`)
|
||||
|
||||
`ReceivedChequeDueSoon` (≤ 2 days) · `ItemBatchExpiring` (≤ N days) · `PaymentOverdue` ·
|
||||
`DriverComplianceExpiring`
|
||||
|
||||
**Phase 2 — later**
|
||||
|
||||
`PoCancelled` / `PoClosed` · `GrnShortOrDamaged` · `LoadingSheetConfirmed` · `InvoiceFinalized` ·
|
||||
`ReturnCreated` / `ClaimSettled` · `PurchaseReturnCreated` · `ApprovalPending` ·
|
||||
`RouteDeviationFlagged` · `RoleAssigned` / `ScopeChanged` · `SyncFailed`
|
||||
|
||||
Excluded on purpose: plain master-data CRUD (brand, category, UOM, vehicle, region).
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/notifications?unreadOnly=&page=&pageSize=` | Caller's inbox, newest first |
|
||||
| `GET /api/notifications/unread-count` | Badge count |
|
||||
| `PATCH /api/notifications/{id}/read` | Mark one read (own row only) |
|
||||
| `PATCH /api/notifications/read-all` | Mark all read |
|
||||
| `DELETE /api/notifications/{id}` | Remove caller's recipient row |
|
||||
| `WS /hubs/notifications` | SignalR; server → client event `notification` |
|
||||
|
||||
---
|
||||
|
||||
## Backend structure
|
||||
|
||||
```
|
||||
Features/Notifications/ (namespace Distribution.Features.Notifications — "Notification" alone clashes with Models.Notification)
|
||||
NotificationController.cs thin, no UoW
|
||||
NotificationService.cs resolve → persist → push; owns uow.CommitAsync()
|
||||
INotificationRepository.cs / NotificationRepository.cs
|
||||
NotificationRecipientResolver.cs Users / Permission / Scope / PermissionInScope / ReportsTo
|
||||
NotificationTypes.cs constants + definition (severity, title/message template)
|
||||
NotificationDTOs.cs
|
||||
NotificationHub.cs SignalR hub
|
||||
NotificationCleanupJob.cs daily retention purge
|
||||
NotificationScheduleJob.cs Phase 1b alerts (second pass)
|
||||
Models/Notification.cs, NotificationRecipient.cs
|
||||
```
|
||||
|
||||
### Code reference
|
||||
|
||||
**Registry** — services never pass string literals:
|
||||
```csharp
|
||||
public static class NotificationTypes
|
||||
{
|
||||
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
|
||||
// ...
|
||||
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All = new Dictionary<string, NotificationTypeDefinition>
|
||||
{
|
||||
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
|
||||
"Delivery failed", "Delivery to {StoreName} failed."),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Service contract** — never throws; failures are logged:
|
||||
```csharp
|
||||
public interface INotificationService
|
||||
{
|
||||
// recipients = union of the rules, then resolved, filtered (inactive, actor) and de-duplicated
|
||||
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
||||
}
|
||||
// RecipientRule: Users(ids) | Permission(code) | Scope(kind, id) | PermissionInScope(code, kind, id) | ReportsTo(userId) | RouteOwner(kind, id)
|
||||
// NotificationArgs: Values (template tokens), EntityType, EntityId (Guid), Link, CreatedByUserId (Guid?)
|
||||
```
|
||||
|
||||
**Calling it from an existing service:**
|
||||
```csharp
|
||||
await _uow.CommitAsync(ct);
|
||||
await _notifications.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||
[RecipientRule.Users(route.FK_OwnerUserId),
|
||||
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Region, route.FK_RegionId)],
|
||||
new(new { StoreName = store.StoreName }, "DeliveryTask", task.Id,
|
||||
$"/operations/delivery-tasks/{task.Id}", currentUserId), ct);
|
||||
```
|
||||
|
||||
**Program.cs:**
|
||||
```csharp
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddHostedService<NotificationCleanupJob>();
|
||||
// CORS: AllowCredentials() with explicit origins
|
||||
app.MapHub<NotificationHub>("/hubs/notifications");
|
||||
```
|
||||
The hub authenticates through the existing `access-token` cookie (read by `AuthMiddleware`); the
|
||||
client connects with `withCredentials: true`. A `?access_token=` query fallback is not needed unless
|
||||
the token ever moves out of the cookie. Users are addressed by their `UserId` claim
|
||||
(`NotificationUserIdProvider`). Each recipient gets its own push because the DTO `Id` is the
|
||||
recipient row id.
|
||||
|
||||
**Frontend** (`contexts/notification-context.tsx`):
|
||||
```tsx
|
||||
const connection = new HubConnectionBuilder()
|
||||
.withUrl(`${API}/hubs/notifications`, { withCredentials: true })
|
||||
.withAutomaticReconnect().build();
|
||||
|
||||
connection.on("notification", (n: NotificationDto) => {
|
||||
setUnread((c) => c + 1);
|
||||
setItems((p) => [n, ...p]);
|
||||
toast(n.title, { description: n.message, action: { label: "Open", onClick: () => router.push(n.link) } });
|
||||
});
|
||||
connection.onreconnected(refetch); // catch up on anything missed
|
||||
```
|
||||
Also: `lib/api/notifications.ts`, bell + popover in the dashboard header, `/notifications` "view all" page.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- Notify **after** a successful commit, inside try/catch + log. A notification failure must never
|
||||
fail or roll back the business action.
|
||||
- Saving and the hub push are separate try/catch blocks (a failed push still leaves the inbox entry).
|
||||
- Unknown type: throws in dev/test, logged and skipped in production. A unit test checks that every
|
||||
constant has a definition.
|
||||
- No per-type config in v1: every type is always delivered.
|
||||
- Click-through is mandatory: `EntityType`, `EntityId` and `Link` are required; clicking marks read.
|
||||
- Retention: `NotificationCleanupJob` deletes read recipients older than 90 days (SysConfig), then
|
||||
events with no recipients left.
|
||||
- No Email/SMS switches in the UI until a sender exists.
|
||||
- `AGENTS.md`: `uow.CommitAsync()` only in services, never `SaveChangesAsync()`, `DateTime.Now` only.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
|
||||
significantly changes the infrastructure needed (third-party provider integration, see
|
||||
[22_integration_api.md](./22_integration_api.md)).
|
||||
Resolved — see **Decisions**. Remaining for later:
|
||||
|
||||
1. Add a cheque / finance permission to the seed, then enable the two cheque triggers.
|
||||
2. Email / SMS / push: see [22_integration_api.md](./22_integration_api.md).
|
||||
3. If the API ever runs on more than one instance, add a SignalR Redis backplane.
|
||||
4. Per-type mute (`NotificationModuleConfig` + admin screen) when requested.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
## Build order
|
||||
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
1. Entities (`Notification`, `NotificationRecipient`) + EF migration.
|
||||
2. `Features/Notifications/` (repository, resolver, service, controller, DTOs, `NotificationTypes`) + retention job.
|
||||
3. SignalR hub, `Program.cs` registration, user-id provider.
|
||||
4. Frontend: API client, provider, bell, toast, click-through, view-all page.
|
||||
|
||||
5. Phase 1 triggers — six wired (recipient resolution is unit-tested; the per-service calls are not,
|
||||
because `Distribution.Tests` does not currently compile). `StockLow` deferred.
|
||||
6. Later: `StockLow`, cheque triggers, Phase 1b scheduled alerts, Phase 2 triggers.
|
||||
|
||||
Remaining before this works end to end: apply the `AddNotifications` migration.
|
||||
|
||||
@@ -82,6 +82,91 @@ Invoice
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### Invoice Line Items & Version History
|
||||
|
||||
> **Implemented.** Class/column names follow the code: the PO is `ShopPO`/`ShopPOItem`, and the
|
||||
> invoice FK is `FK_ShopPOId` (not `FK_OrderId` as sketched above).
|
||||
|
||||
The invoice no longer bills straight from the PO. It owns its own lines, and every change to them is
|
||||
snapshotted, so the original, printed and finalized quantities can all be compared.
|
||||
|
||||
```
|
||||
InvoiceItem — the live billing lines
|
||||
├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
|
||||
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
|
||||
├── UnitPrice (decimal)
|
||||
├── IsFree, FK_SchemeId, SchemeName — free issue copied from the PO line (UnitPrice 0); see 17_promotions_schemes.md
|
||||
└── IsActive, CreatedAt — from Template
|
||||
|
||||
InvoiceVersion — immutable snapshot of the invoice
|
||||
├── FK_InvoiceId, VersionNumber — unique per invoice, starts at 1
|
||||
├── Stage (InvoiceVersionStage) — Created, DraftEdited, Printed, Finalized
|
||||
├── TotalAmount, CreatedBy
|
||||
└── Items → InvoiceVersionItem — FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice
|
||||
```
|
||||
|
||||
- **Add / remove lines (Draft):** `PUT /api/invoices/{id}/items` takes edits keyed by `invoiceItemId`
|
||||
(change quantity/price, or `isRemoved: true` to strike the line — it is kept, excluded from the
|
||||
total, shown struck through on the invoice view and print copy in every status, and can be
|
||||
restored while Draft) or by `itemId` (add a catalog item
|
||||
that was not on the PO; `unitPrice` required, quantity > 0, no duplicate of a live line). Added
|
||||
lines have no `ShopPOItem` (`FK_ShopPOItemId` is nullable → no `orderedQuantity`, no max). Versions
|
||||
store `IsRemoved` per line. At `Finalize`, adjustments are keyed by `invoiceItemId` and can also
|
||||
set `isRemoved`. When the loading sheet is linked to invoices, it must count added lines.
|
||||
- **Original PO quantity** stays on `ShopPOItem.Quantity` (locked once the PO is confirmed) and is
|
||||
returned as `orderedQuantity` on each invoice line.
|
||||
- `InvoiceItem` rows are copied from the PO's active lines at invoice creation (Mode A and Mode B).
|
||||
- A version is written, in the same transaction as the change, when the invoice is:
|
||||
|
||||
| Stage | Written when |
|
||||
|---|---|
|
||||
| `Created` (v1) | Invoice created |
|
||||
| `DraftEdited` | `PUT /api/invoices/{id}/items` while `Draft` |
|
||||
| `Printed` | `POST /api/invoices/{id}/print` — the quantities on the paper copy |
|
||||
| `Finalized` | `POST /api/invoices/{id}/finalize` — after the agent's edits are applied |
|
||||
|
||||
- Comparing `Printed` vs `Finalized` shows what the agent changed on paper.
|
||||
- Migrations `AddInvoiceItems` and `AddInvoiceVersions` backfill lines and one baseline version for
|
||||
invoices that already existed (stage mapped from the invoice's status at migration time, so they
|
||||
have no earlier history).
|
||||
|
||||
### Invoice Screens & Printing (frontend)
|
||||
|
||||
> **Implemented** in `distribution_fe`.
|
||||
|
||||
**Invoice document** — one shared component (`components/invoice/invoice-template.tsx`) renders the
|
||||
invoice for the preview, the draft editor and the print tab:
|
||||
|
||||
- **A4**: `INVOICE` header with Invoice No. (`INV-xxxxxxxx`), date and PO ref; a Status row shown
|
||||
until the invoice is Finalized; *Bill To* (store name, plus address/phone from the PO); item table
|
||||
(Item · Qty · Unit Price · Amount); totals (Subtotal, Total, and Paid / Balance Due when payments
|
||||
exist); payments list; notes lines for the agent's edits on non-finalized copies; signature lines.
|
||||
Soft slate rules, no heavy colour.
|
||||
- **80mm / 58mm receipts**: single black-and-white column with dashed rules (`*** DRAFT ***` /
|
||||
`*** PRINTED ***`, `qty x price` lines, heavy-ruled TOTAL, Paid/Balance, payments, "Thank you!").
|
||||
- Money `Rs. 12,450.00`, quantities up to 3 decimals, dates `dd/MM/yyyy`.
|
||||
- Deleted lines are shown **struck through in every status** and excluded from all totals.
|
||||
- Scheme discount: shown as Subtotal / Scheme discount / Total (`Invoice.DiscountAmount`), re-derived from the current lines on every edit, print and finalize (see 17_promotions_schemes.md). Free-issue lines get a **Free Issue** column and come after the paid lines.
|
||||
- Not included (no data in the app): company/"From" block, logo, bank details, tax, due date.
|
||||
|
||||
**Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a
|
||||
paper-size selector (A4 / 80mm / 58mm), sets `@page` with `margin: 0` (so the browser's own
|
||||
URL/date header and footer are not printed; the A4 sheet pads itself with 14mm), and opens the
|
||||
print dialog automatically. *Print* on a Draft asks who printed it, calls `POST /print`, then opens
|
||||
this tab (the tab is opened inside the click so popup blockers allow it). Printed/Finalized
|
||||
invoices have *Reprint*, which opens the tab without changing status.
|
||||
|
||||
**Draft editor** — pencil icon (Draft only, also *Edit Items* on the invoice page) opens a full page, `/sales/invoices/{id}/edit` (not a modal), showing the invoice template in edit mode: quantity/price
|
||||
inputs, a trash icon per line (undo icon restores a deleted line), and an add-item row aligned to
|
||||
the table columns (item dropdown excluding items already on the invoice, quantity, price
|
||||
auto-filled from the store's price list, Enter or **+** to add). Save is disabled until something
|
||||
changes, a summary shows what is pending (the browser warns before leaving with unsaved edits), and an item picked but not yet added is included on save. Non-Draft invoices redirect back to the invoice page.
|
||||
Each save writes a `DraftEdited` version.
|
||||
|
||||
**Configuration** — *Invoice Creation* card: two toggles, **Auto** (Mode A) and **Manual** (Mode B,
|
||||
default), mutually exclusive (turning one on turns the other off; the active one is locked on), each
|
||||
with a one-line explanation and a process line (`Confirm PO → … → Edit draft → Print → Finalize`).
|
||||
|
||||
### New Enum — `InvoiceStatus`
|
||||
|
||||
```csharp
|
||||
@@ -140,6 +225,7 @@ Draft → Printed → Finalized
|
||||
|---|---|---|
|
||||
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. `Order.InvoiceId` set. |
|
||||
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by `agentId` / `routeId` / `shopId`) and creates the Invoice at `Draft`. `Order.InvoiceId` set. |
|
||||
| Draft (edit) | `PUT /api/invoices/{id}/items` | Office user edits the draft's lines (quantity `0..ordered`, price `>= 0`). Status stays `Draft`; `TotalAmount` recomputed; a `DraftEdited` version is saved. |
|
||||
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
|
||||
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
|
||||
|
||||
@@ -162,7 +248,10 @@ Draft → Printed → Finalized
|
||||
Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
|
||||
7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
|
||||
8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
|
||||
reflects the current line items.
|
||||
reflects the current invoice lines (`InvoiceItem`), not the PO lines.
|
||||
8a. Invoice lines are editable only while `Draft`. At `Finalize`, only quantities can be adjusted
|
||||
(never above the ordered quantity). Every change writes an `InvoiceVersion` snapshot — see
|
||||
[Invoice Line Items & Version History](#invoice-line-items--version-history).
|
||||
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
|
||||
separate axis from `Invoice.Status` — not merged in this pass.
|
||||
10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
|
||||
@@ -201,9 +290,11 @@ Draft → Printed → Finalized
|
||||
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
||||
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
||||
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
|
||||
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
|
||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
||||
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
||||
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
|
||||
Returns and damage are still open, as is the payload shape for them. Scheme discounts and free issues are now handled
|
||||
by the invoice itself (re-evaluated on every edit, print and finalize; free lines only ever shrink automatically) — see
|
||||
[17_promotions_schemes.md](../17_promotions_schemes.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+21
-3
@@ -35,10 +35,28 @@ status transition and stock movement along the way.
|
||||
|
||||
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
|
||||
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
|
||||
(No frontend UI currently calls this endpoint — only the plain `Confirm` action is wired up.)
|
||||
The frontend calls it when **Configuration → Invoice Creation** is set to **Auto** (Mode A, the
|
||||
confirm dialog and PO edit page read the mode); with **Manual** (Mode B, the default) it calls the
|
||||
plain `Confirm` and the invoice is created later from `/sales/orders` → **Create Invoice**.
|
||||
- Lifecycle: `Draft → Printed → Finalized`.
|
||||
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
|
||||
quantity) and recomputes `TotalAmount` from the adjusted lines.
|
||||
- The invoice has its own lines (`InvoiceItem`), copied from the PO's active lines when it is
|
||||
created (either mode). The PO's original quantity stays on `ShopPOItem` and is shown as
|
||||
`orderedQuantity`.
|
||||
- While `Draft`, lines can be edited (`PUT /invoices/{id}/items`): change quantity (`0..ordered`
|
||||
on PO lines) and price (`>= 0`), **delete** a line (it is kept, struck through, excluded from
|
||||
the total, and can be restored), or **add** a catalog item that was not on the PO (no ordered
|
||||
quantity / max). `TotalAmount` is recomputed from the non-deleted lines.
|
||||
- Finalizing applies the agent's edits from the printed copy to the lines (quantity capped
|
||||
between `0` and the ordered quantity, or a line removed) and recomputes `TotalAmount`.
|
||||
- Every change is snapshotted as an `InvoiceVersion` — see
|
||||
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-line-items--version-history).
|
||||
- Frontend, `/sales/orders` row icons: **eye** = invoice preview, **pencil** (Draft) = open the edit page for the
|
||||
draft invoice (`/sales/invoices/[id]/edit`: trash/undo icon per line, add-item row), **printer** (Draft) =
|
||||
mark as printed and open the print tab; on Printed/Finalized the printer reprints without
|
||||
changing status; plus finalize and record payment. `/sales/invoices/[id]` shows the items table
|
||||
(ordered vs billed, Added/Removed badges), the version history, and the same actions.
|
||||
- Invoice document and print tab: see
|
||||
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-screens--printing-frontend).
|
||||
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
|
||||
`Pending → PartiallyPaid → Paid`.
|
||||
|
||||
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
|
||||
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||
import { RegionSalesPie } from './region-sales-pie'
|
||||
import { SalesTrendInteractive } from './sales-trend-interactive'
|
||||
|
||||
export interface DateRange {
|
||||
from: string // yyyy-MM-dd
|
||||
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
||||
function LoadingGrid() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-busy="true">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||
|
||||
const { current: c, previous: p } = sales.kpis
|
||||
const { current: cash, previous: cashPrev } = sales.cash
|
||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||
<Kpi
|
||||
title={title}
|
||||
@@ -184,12 +187,11 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
)
|
||||
|
||||
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
||||
const regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
|
||||
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
{kpi('Net Sales', 'netSales')}
|
||||
{kpi('Gross Sales', 'grossSales')}
|
||||
{kpi('Total Orders', 'orders', false)}
|
||||
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
}
|
||||
/>
|
||||
{kpi('Sales Returns', 'returnValue')}
|
||||
{kpi('Discounts', 'discounts')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Kpi
|
||||
title="Collected"
|
||||
value={fmtMoney(cash.collected)}
|
||||
footer={<Delta current={cash.collected} previous={cashPrev.collected} />}
|
||||
/>
|
||||
<Kpi
|
||||
title="Outstanding"
|
||||
value={fmtMoney(cash.outstanding)}
|
||||
footer={<span className="text-muted-foreground">Unpaid on finalized invoices</span>}
|
||||
/>
|
||||
<Kpi
|
||||
title="Overdue"
|
||||
value={fmtMoney(cash.overdue)}
|
||||
footer={<span className="text-muted-foreground">Unpaid, invoiced over 30 days ago</span>}
|
||||
/>
|
||||
<Kpi
|
||||
title="Collection Rate"
|
||||
value={cash.collectionRatePercent === null ? '—' : `${cash.collectionRatePercent}%`}
|
||||
footer={<span className="text-muted-foreground">Collected ÷ billed (gross − discounts)</span>}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns.
|
||||
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
||||
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
|
||||
Net = gross − customer returns that deduct credit (item exchanges are net zero) − scheme discounts on those invoices
|
||||
(not split by category, so Discounts is 0 when a category is selected). Collected = cash and cheque payments received;
|
||||
credit-method payments stay outstanding. Outstanding and overdue are balances as of the end of the range, and Collected ignores the category filter.
|
||||
Gross profit uses item cost prices and covers only items that have one. Sales targets are not tracked per order yet.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<ChartCard title="Sales Trend" note={`Gross sales per ${sales.granularity}`} className="md:col-span-2">
|
||||
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
||||
</ChartCard>
|
||||
<SalesTrendInteractive className="md:col-span-2" />
|
||||
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||
</ChartCard>
|
||||
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
])}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Sales by Region">
|
||||
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
||||
</ChartCard>
|
||||
<RegionSalesPie rows={sales.byRegion} />
|
||||
|
||||
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||
<MultiAreaChart
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
||||
import type { StockMovementResponse } from '@/types/api'
|
||||
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
||||
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
||||
@@ -54,7 +54,6 @@ const WIDGET_META: WidgetMeta[] = [
|
||||
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
||||
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
||||
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
||||
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
|
||||
|
||||
const DEFAULT_ACTIVE = [
|
||||
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
||||
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
||||
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
|
||||
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
||||
]
|
||||
|
||||
@@ -153,7 +152,6 @@ interface DashboardData {
|
||||
confirmedSheetsCount: number
|
||||
pendingTasksCount: number
|
||||
partialTasksCount: number
|
||||
lowStock: LowStockItemResponse[]
|
||||
movements: StockMovementResponse[]
|
||||
warehouseBalances: WarehouseBalanceRow[]
|
||||
outstandingAmount: number
|
||||
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
|
||||
openRides: [],
|
||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||
pendingTasksCount: 0, partialTasksCount: 0,
|
||||
lowStock: [], movements: [], warehouseBalances: [],
|
||||
movements: [], warehouseBalances: [],
|
||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
||||
@@ -236,14 +234,6 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('lowStock')) {
|
||||
fetches.push(
|
||||
stock.lowStock()
|
||||
.then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) })
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('stockMovements')) {
|
||||
fetches.push(
|
||||
stock.movements({ pageSize: 5 })
|
||||
@@ -400,24 +390,6 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||
</div>
|
||||
)
|
||||
case 'lowStock':
|
||||
return data.lowStock.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
|
||||
</div>
|
||||
{data.lowStock.map(row => (
|
||||
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
|
||||
<span className="text-foreground">{row.itemName}</span>
|
||||
<span className="text-muted-foreground">{row.locationName}</span>
|
||||
<span className="font-semibold text-destructive">{row.quantity}</span>
|
||||
<span className="text-muted-foreground">{row.reorderLevel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'stockMovements':
|
||||
return data.movements.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Label, Pie, PieChart, Sector } from 'recharts'
|
||||
import type { PieSectorShapeProps } from 'recharts/types/polar/Pie'
|
||||
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
|
||||
const MAX_SLICES = 5
|
||||
|
||||
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||
|
||||
/** Top (n-1) regions plus an "Other" slice, so the pie and the picker stay readable. */
|
||||
function topWithOther(rows: Array<{ name: string; amount: number }>) {
|
||||
const sorted = [...rows].filter(r => r.amount > 0).sort((a, b) => b.amount - a.amount)
|
||||
if (sorted.length <= MAX_SLICES) return sorted
|
||||
const head = sorted.slice(0, MAX_SLICES - 1)
|
||||
return [...head, { name: 'Other', amount: sorted.slice(MAX_SLICES - 1).reduce((t, r) => t + r.amount, 0) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive pie of gross sales by region: pick a region (or it defaults to the largest) to pull its slice
|
||||
* out and show its amount and share in the centre.
|
||||
*/
|
||||
export function RegionSalesPie({ rows, className }: { rows: Array<{ name: string; amount: number }>; className?: string }) {
|
||||
const slices = useMemo(
|
||||
() => topWithOther(rows).map((r, i) => ({ key: `r${i}`, name: r.name, amount: r.amount, fill: `var(--color-r${i})` })),
|
||||
[rows],
|
||||
)
|
||||
const config = useMemo(
|
||||
() => Object.fromEntries(slices.map((s, i) => [s.key, { label: s.name, color: SLICE_COLORS[i % SLICE_COLORS.length] }])) as ChartConfig,
|
||||
[slices],
|
||||
)
|
||||
const total = slices.reduce((t, s) => t + s.amount, 0)
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
// Fall back to the largest region when nothing is picked or the picked region left the data (range changed).
|
||||
const activeKey = slices.some(s => s.key === selected) ? selected : slices[0]?.key
|
||||
const activeIndex = slices.findIndex(s => s.key === activeKey)
|
||||
const active = slices[activeIndex]
|
||||
|
||||
const renderShape = useCallback(
|
||||
({ index, outerRadius = 0, ...props }: PieSectorShapeProps) =>
|
||||
index === activeIndex ? (
|
||||
<g>
|
||||
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||
<Sector {...props} outerRadius={outerRadius + 25} innerRadius={outerRadius + 12} />
|
||||
</g>
|
||||
) : (
|
||||
<Sector {...props} outerRadius={outerRadius} />
|
||||
),
|
||||
[activeIndex],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales by Region</CardTitle>
|
||||
<CardDescription>Share of gross sales</CardDescription>
|
||||
{active && (
|
||||
<CardAction>
|
||||
<Select value={activeKey} onValueChange={v => v && setSelected(v)}>
|
||||
<SelectTrigger size="sm" className="w-36" aria-label="Select a region">
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{slices.map((s, i) => (
|
||||
<SelectItem key={s.key} value={s.key} label={s.name}>
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<span className="size-3 shrink-0 rounded-xs" style={{ backgroundColor: SLICE_COLORS[i % SLICE_COLORS.length] }} />
|
||||
{s.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 justify-center">
|
||||
{!active ? (
|
||||
<p className="flex h-52 items-center justify-center text-xs text-muted-foreground">No sales in this period.</p>
|
||||
) : (
|
||||
<ChartContainer config={config} className="mx-auto aspect-square w-full max-w-[260px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel nameKey="key" formatter={(value, _name, item) => (
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{(item.payload as { name: string }).name}</span>
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">{fmtMoney(Number(value))}</span>
|
||||
</div>
|
||||
)} />}
|
||||
/>
|
||||
<Pie data={slices} dataKey="amount" nameKey="key" innerRadius={60} strokeWidth={5} shape={renderShape}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||
const cy = viewBox.cy ?? 0
|
||||
return (
|
||||
<text x={viewBox.cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={cy - 6} className="fill-foreground text-xl font-bold">{fmtCompact(active.amount)}</tspan>
|
||||
<tspan x={viewBox.cx} y={cy + 12} className="fill-muted-foreground text-[11px]">
|
||||
{total > 0 ? `${((active.amount / total) * 100).toFixed(1)}%` : ''}
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { dashboardReports, getApiError } from '@/lib/api/index'
|
||||
import type { DashboardSalesSummary } from '@/lib/api/reports'
|
||||
|
||||
const MAX_DAYS = 90
|
||||
|
||||
const RANGES = [
|
||||
{ value: '90', label: 'Last 3 months' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
sales: { label: 'Gross sales', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
|
||||
const parseIso = (v: string) => new Date(`${v}T00:00:00`)
|
||||
|
||||
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||
const fmtTick = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
const fmtLabel = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
interface Loaded {
|
||||
end: string // yyyy-MM-dd of the last day covered
|
||||
trend: DashboardSalesSummary['trend']
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive area chart of daily gross sales. The last 90 days are loaded once and the
|
||||
* time-range selector narrows them client-side. Days without activity are plotted as 0 so every day shows.
|
||||
*/
|
||||
export function SalesTrendInteractive({ className }: { className?: string }) {
|
||||
const [range, setRange] = useState('90')
|
||||
const [loaded, setLoaded] = useState<Loaded | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const today = new Date()
|
||||
dashboardReports
|
||||
.sales({ from: toIso(addDays(today, -(MAX_DAYS - 1))), to: toIso(today) })
|
||||
.then(res => { if (!cancelled) setLoaded({ end: toIso(today), trend: res.trend }) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
toast.add({ title: 'Failed to load sales trend', description: getApiError(err), type: 'error' })
|
||||
setLoaded(null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const days = Number(range)
|
||||
const data = useMemo(() => {
|
||||
if (!loaded) return []
|
||||
const byDay = new Map(loaded.trend.map(t => [t.period, t]))
|
||||
const end = parseIso(loaded.end)
|
||||
return Array.from({ length: days }, (_, i) => {
|
||||
const date = toIso(addDays(end, i - (days - 1)))
|
||||
const point = byDay.get(date)
|
||||
return { date, sales: point?.sales ?? 0 }
|
||||
})
|
||||
}, [loaded, days])
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.sales, 0)
|
||||
const rangeLabel = RANGES.find(r => r.value === range)?.label.toLowerCase() ?? ''
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Trend</CardTitle>
|
||||
<CardDescription>
|
||||
{loaded ? `${fmtMoney(total)} gross sales in the ${rangeLabel}` : 'Gross sales per day'}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Select value={range} onValueChange={v => v && setRange(v)}>
|
||||
<SelectTrigger size="sm" className="w-40" aria-label="Select time range">
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loaded === undefined ? (
|
||||
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||
) : loaded === null || data.every(d => d.sales === 0) ? (
|
||||
<p className="flex h-[250px] items-center justify-center text-xs text-muted-foreground">
|
||||
{loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`}
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="fillSales" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} tickFormatter={fmtTick} />
|
||||
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" labelFormatter={v => fmtLabel(String(v))} />}
|
||||
/>
|
||||
<Area dataKey="sales" type="natural" fill="url(#fillSales)" stroke="var(--color-sales)" />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user