Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 932d09c135 | |||
| 455ce699ad | |||
| 7d7e9f1318 | |||
| 87e3112a9a | |||
| 801276559d | |||
| b1bda1448e | |||
| 58247a1a0b | |||
| 3a503abe6a | |||
| 855eb44118 | |||
| 33dc4adce2 | |||
| 5b2b39b7bf | |||
| 2c39b34c5f | |||
| 99832df134 | |||
| 2be9d9b396 | |||
| a12e170205 | |||
| fdfca7cf5e | |||
| e84ce29064 | |||
| 2be2928ad1 | |||
| 872fd42c4a | |||
| d79c5e55d0 | |||
| 50d3113c28 | |||
| 34d33571de | |||
| 0f575370d4 | |||
| e4061fa94c | |||
| 5f8bc70174 | |||
| f2d5c9f50a | |||
| 9e39981c9a | |||
| bd8b2d8b44 | |||
| f26557a66e | |||
| 91959913a8 | |||
| 48f607d623 | |||
| 82be251878 | |||
| 209ee7da2d | |||
| 6b9f27573b | |||
| a0df2a1bbf | |||
| d1c3d4e22f | |||
| a80512994b | |||
| 2de9e5e4ad |
@@ -0,0 +1,209 @@
|
|||||||
|
using Distribution.Features.Notifications;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Distribution.Tests.Services;
|
||||||
|
|
||||||
|
public class NotificationServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<INotificationRepository> _repo = new();
|
||||||
|
private readonly Mock<IUnitOfWork> _uow = new();
|
||||||
|
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
|
||||||
|
private readonly Mock<IHubClients> _clients = new();
|
||||||
|
private readonly Mock<IClientProxy> _proxy = new();
|
||||||
|
private readonly NotificationService _sut;
|
||||||
|
|
||||||
|
public NotificationServiceTests()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||||
|
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
|
||||||
|
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
|
||||||
|
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
|
||||||
|
|
||||||
|
_sut = new NotificationService(
|
||||||
|
_repo.Object,
|
||||||
|
new NotificationRecipientResolver(_repo.Object),
|
||||||
|
_hub.Object,
|
||||||
|
_uow.Object,
|
||||||
|
NullLogger<NotificationService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationArgs Args(Guid? actor = null) =>
|
||||||
|
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
|
||||||
|
|
||||||
|
// Registry
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryNotificationTypeConstant_HasADefinition()
|
||||||
|
{
|
||||||
|
var constants = typeof(NotificationTypes)
|
||||||
|
.GetFields()
|
||||||
|
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||||
|
.Select(f => (string)f.GetRawConstantValue()!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(constants);
|
||||||
|
Assert.Equal(constants.Count, constants.Distinct().Count());
|
||||||
|
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
|
||||||
|
{
|
||||||
|
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
|
||||||
|
{
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_ExcludesTheActingUser()
|
||||||
|
{
|
||||||
|
var actor = Guid.NewGuid();
|
||||||
|
var other = Guid.NewGuid();
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||||
|
[RecipientRule.Users(actor, other)], Args(actor));
|
||||||
|
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
var recipient = Assert.Single(saved!.Recipients);
|
||||||
|
Assert.Equal(other, recipient.FK_UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
|
||||||
|
{
|
||||||
|
var a = Guid.NewGuid();
|
||||||
|
var b = Guid.NewGuid();
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
|
||||||
|
|
||||||
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||||
|
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_clients.Verify(c => c.User(a.ToString()), Times.Once);
|
||||||
|
_clients.Verify(c => c.User(b.ToString()), Times.Once);
|
||||||
|
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
|
||||||
|
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
|
||||||
|
{
|
||||||
|
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(() =>
|
||||||
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
|
||||||
|
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
|
||||||
|
{
|
||||||
|
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("socket gone"));
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(() =>
|
||||||
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient rules
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
|
||||||
|
{
|
||||||
|
var both = Guid.NewGuid();
|
||||||
|
var permissionOnly = Guid.NewGuid();
|
||||||
|
var scopeOnly = Guid.NewGuid();
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
|
||||||
|
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
|
||||||
|
|
||||||
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||||
|
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
|
||||||
|
|
||||||
|
Assert.Equal([both], ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
|
||||||
|
{
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
var supervisor = Guid.NewGuid();
|
||||||
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
|
||||||
|
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
|
||||||
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
|
||||||
|
{
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
var owner = Guid.NewGuid();
|
||||||
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
|
||||||
|
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
|
||||||
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
|
||||||
|
{
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
|
||||||
|
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
|
||||||
|
|
||||||
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_DeduplicatesUsersAcrossRules()
|
||||||
|
{
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
|
||||||
|
|
||||||
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||||
|
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
|
||||||
|
|
||||||
|
Assert.Equal([user], ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
public DbSet<Invoice> Invoices { get; set; }
|
public DbSet<Invoice> Invoices { get; set; }
|
||||||
|
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||||||
|
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
|
||||||
|
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
|
||||||
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
@@ -66,12 +69,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||||
public DbSet<SysConfig> SysConfigs { get; set; }
|
public DbSet<SysConfig> SysConfigs { get; set; }
|
||||||
public DbSet<BankAccount> BankAccounts { get; set; }
|
public DbSet<BankAccount> BankAccounts { get; set; }
|
||||||
public DbSet<GlPostingLog> GlPostingLogs { get; set; }
|
|
||||||
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
||||||
public DbSet<ChequePage> ChequePages { get; set; }
|
public DbSet<ChequePage> ChequePages { get; set; }
|
||||||
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
||||||
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
||||||
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
||||||
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
|
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
|
||||||
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
@@ -174,6 +178,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ShopPOId)
|
.HasForeignKey(i => i.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.Invoice)
|
||||||
|
.WithMany(inv => inv.Items)
|
||||||
|
.HasForeignKey(i => i.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.ShopPOItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasOne(v => v.Invoice)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(v => v.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.InvoiceVersion)
|
||||||
|
.WithMany(v => v.Items)
|
||||||
|
.HasForeignKey(i => i.FK_InvoiceVersionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.ShopPOItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Invoice>()
|
modelBuilder.Entity<Invoice>()
|
||||||
.HasMany(i => i.Payments)
|
.HasMany(i => i.Payments)
|
||||||
.WithOne(p => p.Invoice)
|
.WithOne(p => p.Invoice)
|
||||||
@@ -583,16 +635,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(r => r.FK_RegionId)
|
.HasIndex(r => r.FK_RegionId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
// --- GL posting log ---
|
|
||||||
|
|
||||||
modelBuilder.Entity<GlPostingLog>()
|
|
||||||
.HasIndex(l => new { l.Event, l.SourceId })
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<GlPostingLog>()
|
|
||||||
.Property(l => l.Event)
|
|
||||||
.HasMaxLength(60);
|
|
||||||
|
|
||||||
// --- SysConfig ---
|
// --- SysConfig ---
|
||||||
|
|
||||||
modelBuilder.Entity<SysConfig>()
|
modelBuilder.Entity<SysConfig>()
|
||||||
@@ -719,6 +761,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<SupplierPaymentGRN>()
|
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||||
.HasIndex(a => a.FK_GoodsReceiptId);
|
.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 ---
|
// --- StoreCreditMovement ---
|
||||||
|
|
||||||
modelBuilder.Entity<StoreCreditMovement>()
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ public static class RoleAndPermissionSeed
|
|||||||
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
||||||
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||||
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||||
private static readonly Guid PermFinanceReportView = new("20000000-0000-0000-0000-000000000051");
|
|
||||||
private static readonly Guid PermGlManage = new("20000000-0000-0000-0000-000000000052");
|
|
||||||
|
|
||||||
public static void Apply(ModelBuilder modelBuilder)
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -136,9 +134,7 @@ public static class RoleAndPermissionSeed
|
|||||||
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
|
||||||
new Permission { Id = PermFinanceReportView, Code = "finance-reports.view", Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)", CreatedAt = SeedDate, IsActive = true },
|
|
||||||
new Permission { Id = PermGlManage, Code = "gl.manage", Description = "Set up the General Ledger link and retry failed postings", CreatedAt = SeedDate, IsActive = true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// SuperAdmin gets every permission
|
// SuperAdmin gets every permission
|
||||||
@@ -195,9 +191,7 @@ public static class RoleAndPermissionSeed
|
|||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermFinanceReportView, CreatedAt = SeedDate, IsActive = true },
|
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGlManage, CreatedAt = SeedDate, IsActive = true }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
@@ -8,8 +7,7 @@ namespace Distribution.Features.ChequeBook;
|
|||||||
public class ChequeBookService(
|
public class ChequeBookService(
|
||||||
IChequeBookRepository repo,
|
IChequeBookRepository repo,
|
||||||
IBankAccountRepository bankAccountRepo,
|
IBankAccountRepository bankAccountRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private const string DefaultCurrency = "LKR";
|
private const string DefaultCurrency = "LKR";
|
||||||
|
|
||||||
@@ -173,7 +171,6 @@ public class ChequeBookService(
|
|||||||
|
|
||||||
repo.UpdatePage(page);
|
repo.UpdatePage(page);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
await gl.PostIssuedChequeAsync(page, request.Action, ct);
|
|
||||||
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,27 @@ namespace Distribution.Features.DashboardReport;
|
|||||||
|
|
||||||
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
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(
|
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);
|
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||||
|
|
||||||
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
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 TrendPoint(string Period, decimal Sales, decimal Returns);
|
||||||
|
|
||||||
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
||||||
|
|
||||||
public record SalesSummaryResponse(
|
public record SalesSummaryResponse(
|
||||||
DateTime From, DateTime To, string Granularity,
|
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);
|
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||||
|
|
||||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||||
|
|||||||
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
public Guid RepId { get; set; }
|
public Guid RepId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
|
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
|
||||||
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
// 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)
|
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
||||||
{
|
{
|
||||||
var start = f.From.Date;
|
var start = f.From.Date;
|
||||||
var end = f.To.Date.AddDays(1);
|
var end = f.To.Date.AddDays(1);
|
||||||
var q =
|
var q =
|
||||||
from li in db.ShopPOItems.AsNoTracking()
|
from li in db.InvoiceItems.AsNoTracking()
|
||||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
where li.IsActive && !li.IsRemoved
|
||||||
where inv.Status == InvoiceStatus.Finalized
|
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
|
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
|
||||||
select new SoldLine
|
select new SoldLine
|
||||||
{
|
{
|
||||||
Date = inv.FinalizedAt!.Value.Date,
|
Date = li.Invoice.FinalizedAt!.Value.Date,
|
||||||
PoId = li.FK_ShopPOId,
|
PoId = li.Invoice.FK_ShopPOId,
|
||||||
Value = li.QuantityFulfilled * li.UnitPrice,
|
Value = li.Quantity * li.UnitPrice,
|
||||||
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
|
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
|
||||||
CategoryId = li.Item.FK_CategoryId,
|
CategoryId = li.Item.FK_CategoryId,
|
||||||
CategoryName = li.Item.Category.Name,
|
CategoryName = li.Item.Category.Name,
|
||||||
RegionId = li.ShopPO.Store.Route.FK_RegionId,
|
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
|
||||||
RegionName = li.ShopPO.Store.Route.Region.Name,
|
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
|
||||||
RepId = li.ShopPO.RaisedById,
|
RepId = li.Invoice.ShopPO.RaisedById,
|
||||||
};
|
};
|
||||||
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
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);
|
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 start = f.From.Date;
|
||||||
var end = f.To.Date.AddDays(1);
|
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()
|
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.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);
|
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
return q;
|
return q;
|
||||||
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||||
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
||||||
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 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) =>
|
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
|
|||||||
public class DashboardReportService(IDashboardReportRepository repo)
|
public class DashboardReportService(IDashboardReportRepository repo)
|
||||||
{
|
{
|
||||||
public const int MaxRangeDays = 366;
|
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)
|
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)
|
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;
|
var gp = t.CostedSales - t.CostOfSales;
|
||||||
return new SalesKpis(
|
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,
|
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
||||||
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
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)
|
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||||
{
|
{
|
||||||
var days = (f.To - f.From).Days + 1;
|
var days = (f.To - f.From).Days + 1;
|
||||||
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
|
|
||||||
var current = await repo.GetSalesTotalsAsync(f);
|
var current = await repo.GetSalesTotalsAsync(f);
|
||||||
var previous = await repo.GetSalesTotalsAsync(prev);
|
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 sales = await repo.GetDailySalesAsync(f);
|
||||||
var returns = await repo.GetDailyReturnsAsync(f);
|
var returns = await repo.GetDailyReturnsAsync(f);
|
||||||
|
|
||||||
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
return new SalesSummaryResponse(
|
return new SalesSummaryResponse(
|
||||||
f.From, f.To, daily ? "day" : "month",
|
f.From, f.To, daily ? "day" : "month",
|
||||||
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
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,
|
trend,
|
||||||
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||||
(await repo.GetSalesByRegionAsync(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
|
public interface IDashboardReportRepository
|
||||||
{
|
{
|
||||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||||
|
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
|
||||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.LoadingEvent;
|
using Distribution.Features.LoadingEvent;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
|
|||||||
IShopPORepository shopPORepo,
|
IShopPORepository shopPORepo,
|
||||||
ILoadingSheetRepository loadingSheetRepo,
|
ILoadingSheetRepository loadingSheetRepo,
|
||||||
StockMover mover,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string DeliveriesLink = "/operations/deliveries";
|
||||||
|
|
||||||
|
// Route owner + managers whose scope covers the store being delivered to.
|
||||||
|
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
|
||||||
|
[
|
||||||
|
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
|
||||||
|
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
|
||||||
|
];
|
||||||
|
|
||||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
new(t.Id,
|
new(t.Id,
|
||||||
t.FK_RideId,
|
t.FK_RideId,
|
||||||
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (!fullyDelivered && shopPO.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskPartiallyDelivered,
|
||||||
|
DeliveryRecipients(shopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
|
||||||
|
ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(taskId);
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (task.ShopPO?.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskFailed,
|
||||||
|
DeliveryRecipients(task.ShopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
|
||||||
|
ct);
|
||||||
|
|
||||||
return ToResponse(task);
|
return ToResponse(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.FinanceReport;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Proxies the General Ledger service's reports (TrialBalance, GeneralLedger, BalanceSheet, ProfitAndLoss, CashFlow,
|
|
||||||
/// BudgetVsActual, TaxSummary). Query parameters are forwarded as-is; Json is returned as the GL data payload and
|
|
||||||
/// Pdf/Csv are returned as downloadable files.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/reports/finance")]
|
|
||||||
public class FinanceReportController(IGeneralLedgerClient gl) : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>Active GL accounts (code + name) for the report filters' account dropdown.</summary>
|
|
||||||
[HttpGet("accounts")]
|
|
||||||
[Authorize("finance-reports.view")]
|
|
||||||
public async Task<IActionResult> Accounts(CancellationToken ct)
|
|
||||||
{
|
|
||||||
GeneralLedgerResult result;
|
|
||||||
try { result = await gl.SendAsync(HttpMethod.Get, "v1/accounts?isActive=true", null, ct); }
|
|
||||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
|
||||||
|
|
||||||
if (!result.Success || GlJson.Data(result) is not { } data)
|
|
||||||
return StatusCode(502, new { error = GlJson.Message(result) ?? "Could not load GL accounts." });
|
|
||||||
|
|
||||||
var items = GlJson.Get(data, "items");
|
|
||||||
var accounts = items is { ValueKind: JsonValueKind.Array }
|
|
||||||
? items.Value.EnumerateArray()
|
|
||||||
.Select(a => new { accountCode = GlJson.GetString(a, "accountCode") ?? "", accountName = GlJson.GetString(a, "accountName") ?? "" })
|
|
||||||
.OrderBy(a => a.accountCode, StringComparer.Ordinal)
|
|
||||||
.ToList()
|
|
||||||
: [];
|
|
||||||
return Ok(accounts);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{reportType}")]
|
|
||||||
[Authorize("finance-reports.view")]
|
|
||||||
public async Task<IActionResult> Get(string reportType, [FromQuery] string outputFormat = "Json", CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var query = new StringBuilder($"v1/reports?reportType={Uri.EscapeDataString(reportType)}&outputFormat={Uri.EscapeDataString(outputFormat)}");
|
|
||||||
foreach (var (key, values) in Request.Query)
|
|
||||||
{
|
|
||||||
if (key is "reportType" or "outputFormat") continue;
|
|
||||||
foreach (var v in values)
|
|
||||||
query.Append('&').Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(v ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
GeneralLedgerResult result;
|
|
||||||
try { result = await gl.SendAsync(HttpMethod.Get, query.ToString(), null, ct); }
|
|
||||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
|
||||||
|
|
||||||
if (!result.Success)
|
|
||||||
return StatusCode(result.StatusCode is >= 400 and < 500 ? result.StatusCode : 502,
|
|
||||||
new { error = GlJson.Message(result) ?? "The General Ledger service rejected the request." });
|
|
||||||
|
|
||||||
var data = GlJson.Data(result);
|
|
||||||
if (data is null) return StatusCode(502, new { error = "The General Ledger service returned no data." });
|
|
||||||
|
|
||||||
if (!outputFormat.Equals("Json", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var base64 = GlJson.GetString(data.Value, "contentBase64");
|
|
||||||
if (base64 is null) return StatusCode(502, new { error = "The General Ledger service returned no file." });
|
|
||||||
return File(Convert.FromBase64String(base64),
|
|
||||||
GlJson.GetString(data.Value, "contentType") ?? "application/octet-stream",
|
|
||||||
GlJson.GetString(data.Value, "fileName"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Content(data.Value.GetRawText(), "application/json");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GeneralLedgerResult(int StatusCode, bool Success, JsonElement? Body);
|
|
||||||
|
|
||||||
public interface IGeneralLedgerClient
|
|
||||||
{
|
|
||||||
Task<bool> IsHealthyAsync(CancellationToken ct = default);
|
|
||||||
Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service-to-service client for the General Ledger microservice. The GL service is not user-facing:
|
|
||||||
/// every request carries the shared X-Api-Key (GeneralLedger:ApiKey, must equal the GL service's ApiKey:Value).
|
|
||||||
/// <paramref name="path"/> is relative to GeneralLedger:BaseUrl and carries the API version (e.g. "v1/accounts", "v1/journal-entries"), so moving a call to v2 is a one-path change.
|
|
||||||
/// </summary>
|
|
||||||
public class GeneralLedgerClient(HttpClient http) : IGeneralLedgerClient
|
|
||||||
{
|
|
||||||
public async Task<bool> IsHealthyAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var response = await http.GetAsync("/health", ct);
|
|
||||||
return response.IsSuccessStatusCode;
|
|
||||||
}
|
|
||||||
catch (HttpRequestException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
|
||||||
if (payload is not null) request.Content = JsonContent.Create(payload);
|
|
||||||
|
|
||||||
using var response = await http.SendAsync(request, ct);
|
|
||||||
JsonElement? body = null;
|
|
||||||
if (response.Content.Headers.ContentLength != 0)
|
|
||||||
{
|
|
||||||
try { body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct); }
|
|
||||||
catch (JsonException) { }
|
|
||||||
}
|
|
||||||
return new GeneralLedgerResult((int)response.StatusCode, response.IsSuccessStatusCode, body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
using Distribution.Data;
|
|
||||||
using Distribution.Infra.Authorization;
|
|
||||||
using Distribution.Models;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GlSetupRequest(int? Year);
|
|
||||||
|
|
||||||
public record YearEndCloseRequest(int Year);
|
|
||||||
|
|
||||||
public record OpeningBalanceRequest(DateOnly Date, string? Narration, List<OpeningBalanceLine> Lines);
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/gl")]
|
|
||||||
public class GlController(
|
|
||||||
GlSetupService setup,
|
|
||||||
GlPostingService posting,
|
|
||||||
IGeneralLedgerClient gl,
|
|
||||||
Distribution.Features.SysConfig.ISysConfigRepository configRepo,
|
|
||||||
AppDBContext context,
|
|
||||||
ICurrentUserService currentUser) : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpGet("status")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Status()
|
|
||||||
{
|
|
||||||
var mapping = new Dictionary<string, string?>();
|
|
||||||
foreach (var key in GlKeys.AllAccountKeys)
|
|
||||||
mapping[key] = (await configRepo.GetByKeyAsync(key))?.Value;
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
glReachable = await gl.IsHealthyAsync(),
|
|
||||||
setupDone = (await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value == "true",
|
|
||||||
openingBalancePosted = await context.GlPostingLogs.AnyAsync(l => l.Event == "OpeningBalance" && l.Status == GlPostingStatus.Posted),
|
|
||||||
closedYears = await posting.GetClosedYearsAsync(),
|
|
||||||
mapping,
|
|
||||||
posted = await context.GlPostingLogs.CountAsync(l => l.Status == GlPostingStatus.Posted),
|
|
||||||
failed = await context.GlPostingLogs.CountAsync(l => l.Status != GlPostingStatus.Posted)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("setup")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Setup(GlSetupRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try { return Ok(await setup.RunAsync(request.Year, currentUser.UserId, ct)); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("opening-balances")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> OpeningBalances(OpeningBalanceRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var row = await posting.PostOpeningBalanceAsync(request.Date, request.Narration, request.Lines, ct);
|
|
||||||
return row.Status == GlPostingStatus.Posted
|
|
||||||
? Ok(new { status = row.Status, glJournalNo = row.GlJournalNo })
|
|
||||||
: StatusCode(502, new { error = row.Error ?? "The General Ledger service did not accept the entry. It was saved and can be retried." });
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("year-end/preview")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> YearEndPreview([FromQuery] int year, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try { return Ok(await posting.GetYearEndPreviewAsync(year, ct)); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("year-end/close")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> YearEndClose(YearEndCloseRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try { return Ok(await posting.CloseYearAsync(request.Year, ct)); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("postings")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Postings([FromQuery] GlPostingStatus? status, [FromQuery] int take = 100)
|
|
||||||
{
|
|
||||||
var query = context.GlPostingLogs.AsQueryable();
|
|
||||||
if (status is not null) query = query.Where(l => l.Status == status);
|
|
||||||
var rows = await query.OrderByDescending(l => l.CreatedAt).Take(Math.Clamp(take, 1, 500))
|
|
||||||
.Select(l => new { l.Id, l.Event, l.SourceId, l.Reference, l.Status, l.GlJournalNo, l.Error, l.Attempts, l.LastAttemptAt, l.CreatedAt })
|
|
||||||
.ToListAsync();
|
|
||||||
return Ok(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("postings/retry")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Retry(CancellationToken ct) =>
|
|
||||||
Ok(new { posted = await posting.RetryFailedAsync(ct) });
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
/// <summary>Small readers for the GL service's ApiResponse envelope ({statusCode, success, message, data}).</summary>
|
|
||||||
public static class GlJson
|
|
||||||
{
|
|
||||||
public static JsonElement? Data(GeneralLedgerResult result) =>
|
|
||||||
result.Body is { ValueKind: JsonValueKind.Object } body && Get(body, "data") is { } data ? data : null;
|
|
||||||
|
|
||||||
public static string? Message(GeneralLedgerResult result) =>
|
|
||||||
result.Body is { ValueKind: JsonValueKind.Object } body ? Get(body, "message")?.GetString()
|
|
||||||
?? Get(body, "Message")?.GetString() : null;
|
|
||||||
|
|
||||||
/// <summary>Case-insensitive property lookup (GL success bodies are camelCase, its error bodies PascalCase).</summary>
|
|
||||||
public static JsonElement? Get(JsonElement element, string name)
|
|
||||||
{
|
|
||||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
|
||||||
foreach (var p in element.EnumerateObject())
|
|
||||||
if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) return p.Value;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? GetString(JsonElement element, string name)
|
|
||||||
{
|
|
||||||
var v = Get(element, name);
|
|
||||||
return v is null || v.Value.ValueKind == JsonValueKind.Null ? null
|
|
||||||
: v.Value.ValueKind == JsonValueKind.String ? v.Value.GetString() : v.Value.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static long? GetLong(JsonElement element, string name) =>
|
|
||||||
long.TryParse(GetString(element, name), out var n) ? n : null;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
/// <summary>SysConfig keys holding the GL account codes Distribution posts to (created by GlSetupService).</summary>
|
|
||||||
public static class GlKeys
|
|
||||||
{
|
|
||||||
public const string SetupDone = "Gl.Setup.Done";
|
|
||||||
|
|
||||||
public const string CashEquivalents = "Gl.Account.CashEquivalents"; // parent of Cash in Hand + Bank Accounts (GL cash root)
|
|
||||||
public const string CashInHand = "Gl.Account.CashInHand"; // parent of every Distribution Cash account
|
|
||||||
public const string BankAccounts = "Gl.Account.BankAccounts"; // parent of every Distribution Bank account
|
|
||||||
public const string ChequesInHand = "Gl.Account.ChequesInHand";
|
|
||||||
public const string Receivable = "Gl.Account.Receivable";
|
|
||||||
public const string Inventory = "Gl.Account.Inventory";
|
|
||||||
public const string InputVat = "Gl.Account.InputVat";
|
|
||||||
public const string InputNbt = "Gl.Account.InputNbt";
|
|
||||||
public const string Payable = "Gl.Account.Payable";
|
|
||||||
public const string ChequesIssued = "Gl.Account.ChequesIssued";
|
|
||||||
public const string Equity = "Gl.Account.Equity"; // header; parent of the equity accounts below
|
|
||||||
public const string ShareCapital = "Gl.Account.ShareCapital";
|
|
||||||
public const string RetainedEarnings = "Gl.Account.RetainedEarnings";
|
|
||||||
public const string Drawings = "Gl.Account.Drawings";
|
|
||||||
public const string GeneralReserve = "Gl.Account.GeneralReserve";
|
|
||||||
public const string RevaluationReserve = "Gl.Account.RevaluationReserve";
|
|
||||||
public const string Sales = "Gl.Account.Sales";
|
|
||||||
|
|
||||||
public static readonly string[] AllAccountKeys =
|
|
||||||
[
|
|
||||||
CashEquivalents, CashInHand, BankAccounts, ChequesInHand, Receivable, Inventory,
|
|
||||||
InputVat, InputNbt, Payable, ChequesIssued, Equity, ShareCapital, RetainedEarnings, Drawings,
|
|
||||||
GeneralReserve, RevaluationReserve, Sales
|
|
||||||
];
|
|
||||||
|
|
||||||
public static bool IsGlKey(string key) => key == SetupDone || AllAccountKeys.Contains(key);
|
|
||||||
}
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Distribution.Data;
|
|
||||||
using Distribution.Features.Grn;
|
|
||||||
using Distribution.Features.SysConfig;
|
|
||||||
using Distribution.Infra.UoW;
|
|
||||||
using Distribution.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GlLine(string AccountCode, decimal Debit, decimal Credit);
|
|
||||||
|
|
||||||
/// <summary>What closing a financial year would post. Closable is false with a Reason when it cannot be closed.</summary>
|
|
||||||
public record YearEndPreview(int Year, string StartDate, string EndDate, decimal TotalIncome, decimal TotalExpenses,
|
|
||||||
decimal NetProfit, bool Closable, string? Reason, bool AlreadyClosed);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One opening balance: Account is a key from OpeningAccounts (e.g. "Inventory", "ShareCapital") or "BankAccount"
|
|
||||||
/// with BankAccountId. Amount is the account's normal balance (>= 0); the debit/credit side comes from the account.
|
|
||||||
/// </summary>
|
|
||||||
public record OpeningBalanceLine(string Account, Guid? BankAccountId, decimal Amount);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mirrors Distribution business events into the General Ledger as journal entries. Every public Post* method is
|
|
||||||
/// called AFTER the business transaction has committed and never throws: a GL problem is recorded in GlPostingLogs
|
|
||||||
/// (status Failed, payload kept) and can be retried later, so it can never block a sale, payment or cheque action.
|
|
||||||
/// </summary>
|
|
||||||
public class GlPostingService(
|
|
||||||
IGeneralLedgerClient gl,
|
|
||||||
ISysConfigRepository configRepo,
|
|
||||||
GlSetupService setup,
|
|
||||||
AppDBContext context,
|
|
||||||
IUnitOfWork uow,
|
|
||||||
ILogger<GlPostingService> logger)
|
|
||||||
{
|
|
||||||
private record Payload(string EntryDate, string JournalType, string SourceModule, string Reference, string Narration,
|
|
||||||
string PreparedBy, List<PayloadLine> Lines);
|
|
||||||
|
|
||||||
private record PayloadLine(string AccountCode, decimal DebitAmount, decimal CreditAmount);
|
|
||||||
|
|
||||||
// ── Events ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public Task PostInvoiceFinalizedAsync(Models.Invoice invoice, CancellationToken ct = default) =>
|
|
||||||
PostAsync("InvoiceFinalized", invoice.Id, invoice.FinalizedAt ?? DateTime.Now, "INVOICE", $"Invoice {invoice.Id} finalized",
|
|
||||||
async () => invoice.TotalAmount <= 0 ? null :
|
|
||||||
[
|
|
||||||
new GlLine(await Acct(GlKeys.Receivable), invoice.TotalAmount, 0),
|
|
||||||
new GlLine(await Acct(GlKeys.Sales), 0, invoice.TotalAmount)
|
|
||||||
], ct);
|
|
||||||
|
|
||||||
public Task PostCustomerPaymentAsync(Models.Payment payment, CancellationToken ct = default) =>
|
|
||||||
PostAsync("CustomerPayment", payment.Id, payment.CollectedAt, "PAYMENT", $"Customer payment for invoice {payment.FK_InvoiceId}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
if (payment.Amount <= 0 || payment.Method == PaymentMethod.Credit) return null;
|
|
||||||
var debit = payment.Method == PaymentMethod.Cash
|
|
||||||
? await BankCode(payment.FK_BankAccountId)
|
|
||||||
: await Acct(GlKeys.ChequesInHand);
|
|
||||||
return [new GlLine(debit, payment.Amount, 0), new GlLine(await Acct(GlKeys.Receivable), 0, payment.Amount)];
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
/// <summary>Deposit / Return / Cancel of a received cheque that came from a customer payment.</summary>
|
|
||||||
public Task PostReceivedChequeAsync(Models.ReceivedCheque cheque, ReceivedChequeStatusAction action, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (action is not (ReceivedChequeStatusAction.Deposit or ReceivedChequeStatusAction.Return or ReceivedChequeStatusAction.Cancel))
|
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
var date = action == ReceivedChequeStatusAction.Deposit ? cheque.DepositDate ?? DateTime.Now : DateTime.Now;
|
|
||||||
return PostAsync($"ReceivedCheque{action}", cheque.Id, date, "CHEQUE", $"Received cheque {cheque.ChequeNo} {action}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
// Only cheques created by a customer payment were posted to Cheques-in-hand, so only they can move.
|
|
||||||
if (!await context.Payments.AnyAsync(p => p.FK_ReceivedChequeId == cheque.Id, ct)) return null;
|
|
||||||
var inHand = await Acct(GlKeys.ChequesInHand);
|
|
||||||
return action switch
|
|
||||||
{
|
|
||||||
ReceivedChequeStatusAction.Deposit =>
|
|
||||||
[new GlLine(await BankCode(cheque.FK_DepositBankAccountId), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)],
|
|
||||||
ReceivedChequeStatusAction.Return =>
|
|
||||||
[new GlLine(inHand, cheque.Amount, 0), new GlLine(await BankCode(cheque.FK_DepositBankAccountId), 0, cheque.Amount)],
|
|
||||||
_ => [new GlLine(await Acct(GlKeys.Receivable), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)]
|
|
||||||
};
|
|
||||||
}, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task PostGrnAsync(Models.GoodsReceipt grn, CancellationToken ct = default) =>
|
|
||||||
PostAsync("Grn", grn.Id, grn.ReceivedAt, "GRN", $"Goods received {grn.Id}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
decimal net = 0, nbt = 0, vat = 0;
|
|
||||||
foreach (var i in grn.Items.Where(i => i.IsActive))
|
|
||||||
{
|
|
||||||
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
|
|
||||||
net += a.Gross - a.Discount;
|
|
||||||
nbt += a.Nbt;
|
|
||||||
vat += a.Vat;
|
|
||||||
}
|
|
||||||
var total = net + nbt + vat;
|
|
||||||
if (total <= 0) return null;
|
|
||||||
|
|
||||||
var lines = new List<GlLine> { new(await Acct(GlKeys.Inventory), net, 0) };
|
|
||||||
if (nbt > 0) lines.Add(new GlLine(await Acct(GlKeys.InputNbt), nbt, 0));
|
|
||||||
if (vat > 0) lines.Add(new GlLine(await Acct(GlKeys.InputVat), vat, 0));
|
|
||||||
lines.Add(new GlLine(await Acct(GlKeys.Payable), 0, total));
|
|
||||||
return lines;
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
public Task PostSupplierPaymentAsync(Models.SupplierPayment payment, CancellationToken ct = default) =>
|
|
||||||
PostAsync("SupplierPayment", payment.Id, payment.PaidAt, "SUPPLIER_PAYMENT", $"Supplier payment {payment.Id}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
if (payment.Amount <= 0) return null;
|
|
||||||
var credit = payment.Method == PaymentMethod.Cash
|
|
||||||
? await BankCode(payment.FK_BankAccountId)
|
|
||||||
: await Acct(GlKeys.ChequesIssued);
|
|
||||||
return [new GlLine(await Acct(GlKeys.Payable), payment.Amount, 0), new GlLine(credit, 0, payment.Amount)];
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
/// <summary>Clear / Bounce / Cancel of a cheque issued through a supplier payment.</summary>
|
|
||||||
public Task PostIssuedChequeAsync(Models.ChequePage page, ChequePageStatusAction action, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (action == ChequePageStatusAction.Void) return Task.CompletedTask;
|
|
||||||
|
|
||||||
var date = action == ChequePageStatusAction.Clear ? page.ClearedDate ?? DateTime.Now : DateTime.Now;
|
|
||||||
return PostAsync($"IssuedCheque{action}", page.Id, date, "CHEQUE", $"Issued cheque {page.ChequeNo} {action}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
var amount = page.Amount ?? 0;
|
|
||||||
if (amount <= 0 || !await context.SupplierPayments.AnyAsync(p => p.FK_ChequePageId == page.Id, ct)) return null;
|
|
||||||
var issued = await Acct(GlKeys.ChequesIssued);
|
|
||||||
if (action == ChequePageStatusAction.Clear)
|
|
||||||
{
|
|
||||||
var bookBankId = await context.ChequeBooks.Where(b => b.Id == page.FK_ChequeBookId)
|
|
||||||
.Select(b => (Guid?)b.FK_BankAccountId).FirstOrDefaultAsync(ct);
|
|
||||||
return [new GlLine(issued, amount, 0), new GlLine(await BankCode(bookBankId), 0, amount)];
|
|
||||||
}
|
|
||||||
// Bounce / Cancel: the cheque no longer settles the payable, so put it back.
|
|
||||||
return [new GlLine(issued, amount, 0), new GlLine(await Acct(GlKeys.Payable), 0, amount)];
|
|
||||||
}, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Posts the opening balances (asset side debited, liability/equity side credited). Zero amounts are skipped, so the
|
|
||||||
/// form can list every account with a default of 0. Reports the outcome so the caller can show a failure.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<GlPostingLog> PostOpeningBalanceAsync(DateOnly date, string? narration, List<OpeningBalanceLine> input, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
|
|
||||||
throw new InvalidOperationException("Run the General Ledger setup first.");
|
|
||||||
|
|
||||||
var lines = new List<GlLine>();
|
|
||||||
foreach (var l in input ?? [])
|
|
||||||
{
|
|
||||||
if (l.Amount < 0) throw new InvalidOperationException($"'{l.Account}': the amount cannot be negative.");
|
|
||||||
if (l.Amount == 0) continue;
|
|
||||||
|
|
||||||
if (l.Account == "BankAccount")
|
|
||||||
{
|
|
||||||
lines.Add(new GlLine(await BankCode(l.BankAccountId), l.Amount, 0));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!OpeningAccounts.TryGetValue(l.Account, out var def))
|
|
||||||
throw new InvalidOperationException($"Unknown account '{l.Account}'.");
|
|
||||||
var code = await Acct(def.Key);
|
|
||||||
lines.Add(def.DebitNormal ? new GlLine(code, l.Amount, 0) : new GlLine(code, 0, l.Amount));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.Count == 0)
|
|
||||||
throw new InvalidOperationException("Enter at least one opening balance greater than zero.");
|
|
||||||
|
|
||||||
var debit = lines.Sum(x => x.Debit);
|
|
||||||
var credit = lines.Sum(x => x.Credit);
|
|
||||||
if (debit != credit)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"The opening balances do not balance: assets (debits) total {debit:N2} but liabilities + equity (credits) total {credit:N2}. " +
|
|
||||||
"Adjust Share Capital / Retained Earnings so they match.");
|
|
||||||
|
|
||||||
var sourceId = Guid.NewGuid();
|
|
||||||
await PostAsync("OpeningBalance", sourceId, date.ToDateTime(TimeOnly.MinValue), "OPENING_BALANCE",
|
|
||||||
string.IsNullOrWhiteSpace(narration) ? "Opening balances" : narration.Trim(), () => Task.FromResult<List<GlLine>?>(lines), ct);
|
|
||||||
|
|
||||||
return await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == "OpeningBalance" && l.SourceId == sourceId, ct)
|
|
||||||
?? throw new InvalidOperationException("The opening balances could not be prepared. Check the server log.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Accounts that can carry an opening balance (besides individual cash/bank accounts) and their normal side.</summary>
|
|
||||||
public static readonly Dictionary<string, (string Key, bool DebitNormal)> OpeningAccounts = new()
|
|
||||||
{
|
|
||||||
["ChequesInHand"] = (GlKeys.ChequesInHand, true),
|
|
||||||
["Receivable"] = (GlKeys.Receivable, true),
|
|
||||||
["Inventory"] = (GlKeys.Inventory, true),
|
|
||||||
["InputVat"] = (GlKeys.InputVat, true),
|
|
||||||
["InputNbt"] = (GlKeys.InputNbt, true),
|
|
||||||
["Drawings"] = (GlKeys.Drawings, true),
|
|
||||||
["Payable"] = (GlKeys.Payable, false),
|
|
||||||
["ChequesIssued"] = (GlKeys.ChequesIssued, false),
|
|
||||||
["ShareCapital"] = (GlKeys.ShareCapital, false),
|
|
||||||
["RetainedEarnings"] = (GlKeys.RetainedEarnings, false),
|
|
||||||
["GeneralReserve"] = (GlKeys.GeneralReserve, false),
|
|
||||||
["RevaluationReserve"] = (GlKeys.RevaluationReserve, false),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Year-end close ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private const string YearEndEvent = "YearEndClose";
|
|
||||||
|
|
||||||
private static Guid YearEndSourceId(int year) =>
|
|
||||||
new(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes($"{YearEndEvent}:{year}")));
|
|
||||||
|
|
||||||
/// <summary>Years that have a posted closing journal.</summary>
|
|
||||||
public async Task<List<int>> GetClosedYearsAsync(CancellationToken ct = default) =>
|
|
||||||
(await context.GlPostingLogs
|
|
||||||
.Where(l => l.Event == YearEndEvent && l.Status == GlPostingStatus.Posted)
|
|
||||||
.Select(l => l.Reference)
|
|
||||||
.ToListAsync(ct))
|
|
||||||
.Select(r => int.TryParse(r.Split(':').Last(), out var y) ? y : 0)
|
|
||||||
.Where(y => y > 0).OrderBy(y => y).ToList();
|
|
||||||
|
|
||||||
private record CloseBuild(List<GlLine> Lines, decimal Income, decimal Expenses);
|
|
||||||
|
|
||||||
/// <summary>Reads the year's own P&L from GL (which excludes earlier closing journals) and builds the closing lines.</summary>
|
|
||||||
private async Task<CloseBuild> BuildYearEndCloseAsync(int year, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Get,
|
|
||||||
$"v1/reports?reportType=ProfitAndLoss&outputFormat=Json&periodStart={year}-01-01&periodEnd={year}-12-31", null, ct);
|
|
||||||
if (!result.Success || GlJson.Data(result) is not { } pl)
|
|
||||||
throw new InvalidOperationException($"Could not read the {year} profit and loss from the General Ledger: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
|
||||||
|
|
||||||
// Net CREDIT balance per account: income sections carry their amount as is, expense sections are reported
|
|
||||||
// as positive debits (so flip), unclassified rows are already credit - debit.
|
|
||||||
var netCredit = new Dictionary<string, decimal>();
|
|
||||||
decimal income = 0, expenses = 0;
|
|
||||||
void Collect(string section, int sign)
|
|
||||||
{
|
|
||||||
if (GlJson.Get(pl, section) is not { } s || GlJson.Get(s, "lines") is not { ValueKind: JsonValueKind.Array } sectionLines) return;
|
|
||||||
foreach (var l in sectionLines.EnumerateArray())
|
|
||||||
{
|
|
||||||
var code = GlJson.GetString(l, "accountCode");
|
|
||||||
if (code is null || !decimal.TryParse(GlJson.GetString(l, "amount"), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue;
|
|
||||||
var credit = sign * amount;
|
|
||||||
netCredit[code] = netCredit.GetValueOrDefault(code) + credit;
|
|
||||||
if (credit > 0) income += credit; else expenses += -credit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Collect("sales", 1); Collect("otherIncome", 1);
|
|
||||||
Collect("costOfSales", -1); Collect("distributionExpenses", -1); Collect("administrationExpenses", -1);
|
|
||||||
Collect("otherExpenses", -1); Collect("financialExpenses", -1);
|
|
||||||
Collect("unclassified", 1);
|
|
||||||
|
|
||||||
// Closing: an account with a net credit balance is debited, one with a net debit balance is credited.
|
|
||||||
var lines = netCredit.Where(kv => kv.Value != 0)
|
|
||||||
.Select(kv => kv.Value > 0 ? new GlLine(kv.Key, kv.Value, 0) : new GlLine(kv.Key, 0, -kv.Value)).ToList();
|
|
||||||
var debit = lines.Sum(x => x.Debit);
|
|
||||||
var credit2 = lines.Sum(x => x.Credit);
|
|
||||||
if (debit != credit2)
|
|
||||||
{
|
|
||||||
var retained = await Acct(GlKeys.RetainedEarnings);
|
|
||||||
lines.Add(debit > credit2 ? new GlLine(retained, 0, debit - credit2) : new GlLine(retained, credit2 - debit, 0));
|
|
||||||
}
|
|
||||||
return new CloseBuild(lines, income, expenses);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<YearEndPreview> GetYearEndPreviewAsync(int year, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var start = $"{year}-01-01";
|
|
||||||
var end = $"{year}-12-31";
|
|
||||||
|
|
||||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
|
|
||||||
return new(year, start, end, 0, 0, 0, false, "Run the General Ledger setup first.", false);
|
|
||||||
|
|
||||||
var sourceId = YearEndSourceId(year);
|
|
||||||
var existing = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct);
|
|
||||||
if (existing?.Status == GlPostingStatus.Posted)
|
|
||||||
return new(year, start, end, 0, 0, 0, false, $"{year} has already been closed.", true);
|
|
||||||
|
|
||||||
var build = await BuildYearEndCloseAsync(year, ct);
|
|
||||||
var net = build.Income - build.Expenses;
|
|
||||||
string? reason = null;
|
|
||||||
if (new DateOnly(year, 12, 31) > DateOnly.FromDateTime(DateTime.Now))
|
|
||||||
reason = $"{year} has not ended yet. A year can only be closed after 31 December.";
|
|
||||||
else if (await context.GlPostingLogs.AnyAsync(l => l.Status != GlPostingStatus.Posted && !(l.Event == YearEndEvent && l.SourceId == sourceId), ct))
|
|
||||||
reason = "There are failed or pending General Ledger postings. Retry them first so the year's figures are complete.";
|
|
||||||
else if (build.Lines.Count == 0)
|
|
||||||
reason = $"There is no income or expense activity in {year} to close.";
|
|
||||||
|
|
||||||
return new(year, start, end, build.Income, build.Expenses, net, reason is null, reason, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Posts the closing journal for the year (income/expense into Retained Earnings), then locks the year's periods.</summary>
|
|
||||||
public async Task<object> CloseYearAsync(int year, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var preview = await GetYearEndPreviewAsync(year, ct);
|
|
||||||
if (!preview.Closable) throw new InvalidOperationException(preview.Reason ?? "This year cannot be closed.");
|
|
||||||
|
|
||||||
var build = await BuildYearEndCloseAsync(year, ct);
|
|
||||||
var sourceId = YearEndSourceId(year);
|
|
||||||
await PostAsync(YearEndEvent, sourceId, new DateTime(year, 12, 31), "YEAR_END_CLOSE", $"Year-end closing entry for {year}",
|
|
||||||
() => Task.FromResult<List<GlLine>?>(build.Lines), ct, journalType: "CLOSING", reference: $"{YearEndEvent}:{year}");
|
|
||||||
|
|
||||||
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct)
|
|
||||||
?? throw new InvalidOperationException("The closing entry could not be prepared. Check the server log.");
|
|
||||||
if (row.Status != GlPostingStatus.Posted)
|
|
||||||
throw new InvalidOperationException((row.Error ?? "The General Ledger did not accept the closing entry.") + " Fix the cause and press Close again.");
|
|
||||||
|
|
||||||
var locked = await LockYearPeriodsAsync(year, ct);
|
|
||||||
return new { year, glJournalNo = row.GlJournalNo, netProfit = preview.NetProfit, periodsLocked = locked };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<int> LockYearPeriodsAsync(int year, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var ids = new HashSet<long>();
|
|
||||||
for (var month = 1; month <= 12; month++)
|
|
||||||
{
|
|
||||||
var p = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={year}-{month:00}-15", null, ct);
|
|
||||||
if (p.Success && GlJson.Data(p) is { } pd && GlJson.GetLong(pd, "periodId") is { } id) ids.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var locked = 0;
|
|
||||||
foreach (var id in ids)
|
|
||||||
{
|
|
||||||
var r = await gl.SendAsync(HttpMethod.Put, $"v1/periods/{id}/lock", new { action = "Close", reason = $"Year-end close {year}" }, ct);
|
|
||||||
if (r.Success) locked++;
|
|
||||||
else logger.LogWarning("Could not lock GL period {PeriodId} after closing {Year}: {Message}", id, year, GlJson.Message(r));
|
|
||||||
}
|
|
||||||
return locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Retry ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Re-sends every Failed/Pending posting. Returns how many are now Posted.</summary>
|
|
||||||
public async Task<int> RetryFailedAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var rows = await context.GlPostingLogs
|
|
||||||
.Where(l => l.Status != GlPostingStatus.Posted)
|
|
||||||
.OrderBy(l => l.CreatedAt)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var posted = 0;
|
|
||||||
foreach (var row in rows)
|
|
||||||
{
|
|
||||||
await SendAsync(row, ct);
|
|
||||||
if (row.Status == GlPostingStatus.Posted) posted++;
|
|
||||||
}
|
|
||||||
return posted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Core ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task PostAsync(string evt, Guid sourceId, DateTime date, string sourceModule, string narration,
|
|
||||||
Func<Task<List<GlLine>?>> buildLines, CancellationToken ct, string journalType = "GENERAL", string? reference = null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true") return; // GL not set up yet
|
|
||||||
|
|
||||||
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == evt && l.SourceId == sourceId, ct);
|
|
||||||
if (row is not null && row.Status == GlPostingStatus.Posted) return;
|
|
||||||
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
var lines = await buildLines();
|
|
||||||
if (lines is null || lines.Count == 0) return;
|
|
||||||
|
|
||||||
reference ??= $"{evt}:{sourceId}";
|
|
||||||
var payload = new Payload(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), journalType, sourceModule, reference,
|
|
||||||
narration, "Distribution", lines.Select(l => new PayloadLine(l.AccountCode, l.Debit, l.Credit)).ToList());
|
|
||||||
|
|
||||||
row = new GlPostingLog
|
|
||||||
{
|
|
||||||
Event = evt,
|
|
||||||
SourceId = sourceId,
|
|
||||||
Reference = reference,
|
|
||||||
PayloadJson = JsonSerializer.Serialize(payload, JsonOptions)
|
|
||||||
};
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try { await context.GlPostingLogs.AddAsync(row, ct); await uow.CommitAsync(ct); }
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
await SendAsync(row, ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "GL posting {Event}:{SourceId} failed before it could be sent", evt, sourceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendAsync(GlPostingLog row, CancellationToken ct)
|
|
||||||
{
|
|
||||||
string? journalNo = null;
|
|
||||||
string? error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var payload = JsonSerializer.Deserialize<JsonElement>(row.PayloadJson);
|
|
||||||
var entryDate = payload.GetProperty("entryDate").GetString()!;
|
|
||||||
|
|
||||||
var period = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={entryDate}", null, ct);
|
|
||||||
var periodId = period.Success && GlJson.Data(period) is { } pd ? GlJson.GetLong(pd, "periodId") : null;
|
|
||||||
if (periodId is null)
|
|
||||||
{
|
|
||||||
error = $"No accounting period in GL covers {entryDate}. Run GL setup for that year.";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var body = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(row.PayloadJson, JsonOptions)!;
|
|
||||||
body["periodId"] = JsonSerializer.SerializeToElement(periodId.Value);
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Post, "v1/journal-entries", body, ct);
|
|
||||||
if (result.Success)
|
|
||||||
journalNo = GlJson.Data(result) is { } d ? GlJson.GetString(d, "journalNo") ?? "" : "";
|
|
||||||
else
|
|
||||||
error = GlJson.Message(result) ?? $"GL returned HTTP {result.StatusCode}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
error = ex.Message;
|
|
||||||
logger.LogWarning(ex, "GL posting {Reference} failed", row.Reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.Attempts++;
|
|
||||||
row.LastAttemptAt = DateTime.Now;
|
|
||||||
row.Status = error is null ? GlPostingStatus.Posted : GlPostingStatus.Failed;
|
|
||||||
row.GlJournalNo = journalNo;
|
|
||||||
row.Error = error is null ? null : error.Length > 1000 ? error[..1000] : error;
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try { context.GlPostingLogs.Update(row); await uow.CommitAsync(ct); }
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Account lookups ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task<string> Acct(string key) =>
|
|
||||||
(await configRepo.GetByKeyAsync(key))?.Value is { Length: > 0 } code
|
|
||||||
? code
|
|
||||||
: throw new InvalidOperationException($"GL account mapping '{key}' is missing. Run GL setup.");
|
|
||||||
|
|
||||||
private async Task<string> BankCode(Guid? bankAccountId)
|
|
||||||
{
|
|
||||||
if (bankAccountId is null) throw new InvalidOperationException("No cash/bank account on this transaction.");
|
|
||||||
var account = await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == bankAccountId.Value)
|
|
||||||
?? throw new InvalidOperationException("Cash/bank account not found.");
|
|
||||||
return await setup.EnsureBankAccountAsync(account)
|
|
||||||
?? throw new InvalidOperationException("Could not map the cash/bank account to a GL account. Run GL setup.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Distribution.Data;
|
|
||||||
using Distribution.Features.SysConfig;
|
|
||||||
using Distribution.Infra.UoW;
|
|
||||||
using Distribution.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GlSetupResult(bool Done, List<string> Actions);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One-time (re-runnable) bootstrap of the General Ledger service for Distribution: creates the chart of accounts
|
|
||||||
/// Distribution posts to, a fiscal year with its periods, the GL cash-root config, and a GL account for every
|
|
||||||
/// Distribution cash/bank account. The resulting GL account codes are stored in SysConfig (see GlKeys).
|
|
||||||
/// </summary>
|
|
||||||
public class GlSetupService(
|
|
||||||
IGeneralLedgerClient gl,
|
|
||||||
ISysConfigRepository configRepo,
|
|
||||||
AppDBContext context,
|
|
||||||
IUnitOfWork uow)
|
|
||||||
{
|
|
||||||
private const short Asset = 1, Liability = 2, Equity = 3, Income = 4;
|
|
||||||
|
|
||||||
// key, GL account name, account type, parent key, postable, tag name, tag value.
|
|
||||||
// Operating = working-capital account for the Cash Flow report (cashFlowCategory OPERATING).
|
|
||||||
private record AccountDef(string Key, string Name, short TypeId, string? ParentKey, bool Postable, string? TagField, string? TagValue, bool Operating = false);
|
|
||||||
|
|
||||||
private static readonly AccountDef[] Chart =
|
|
||||||
[
|
|
||||||
new(GlKeys.CashEquivalents, "Cash and Cash Equivalents", Asset, null, false, "balanceSheetClassification", "CURRENT_ASSET"),
|
|
||||||
new(GlKeys.CashInHand, "Cash in Hand", Asset, GlKeys.CashEquivalents, false, null, null),
|
|
||||||
new(GlKeys.BankAccounts, "Bank Accounts", Asset, GlKeys.CashEquivalents, false, null, null),
|
|
||||||
new(GlKeys.ChequesInHand, "Cheques in Hand", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.Receivable, "Trade Receivables", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.Inventory, "Inventory", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.InputVat, "Input VAT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.InputNbt, "Input NBT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.Payable, "Trade Payables", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
|
||||||
new(GlKeys.ChequesIssued, "Cheques Issued - Uncleared", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
|
||||||
new(GlKeys.Equity, "Owner's Equity", Equity, null, false, null, null),
|
|
||||||
new(GlKeys.ShareCapital, "Share Capital / Owner's Capital", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.RetainedEarnings, "Retained Earnings", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.Drawings, "Drawings and Dividends", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.GeneralReserve, "General Reserve", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.RevaluationReserve, "Revaluation Reserve", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.Sales, "Sales Revenue", Income, null, true, "incomeStatementSection", "SALES"),
|
|
||||||
];
|
|
||||||
|
|
||||||
public async Task<GlSetupResult> RunAsync(int? year, Guid? userId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (!await gl.IsHealthyAsync(ct))
|
|
||||||
throw new InvalidOperationException("The General Ledger service is not reachable. Check GeneralLedger:BaseUrl and that the service is running.");
|
|
||||||
|
|
||||||
var actions = new List<string>();
|
|
||||||
var codes = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
foreach (var key in GlKeys.AllAccountKeys)
|
|
||||||
{
|
|
||||||
var existing = (await configRepo.GetByKeyAsync(key))?.Value;
|
|
||||||
if (!string.IsNullOrWhiteSpace(existing)) codes[key] = existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Chart of accounts (skips accounts whose code is already stored and still exists in GL).
|
|
||||||
foreach (var def in Chart)
|
|
||||||
{
|
|
||||||
if (codes.TryGetValue(def.Key, out var code) && await AccountIdAsync(code, ct) is not null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
long? parentId = null;
|
|
||||||
if (def.ParentKey is not null)
|
|
||||||
parentId = await AccountIdAsync(codes[def.ParentKey], ct);
|
|
||||||
|
|
||||||
var created = await CreateAccountAsync(def.Name, def.TypeId, parentId, def.Postable, ct);
|
|
||||||
codes[def.Key] = created.Code;
|
|
||||||
actions.Add($"Created GL account {created.Code} - {def.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags are applied on every run (idempotent), so accounts created by an earlier run are corrected too.
|
|
||||||
foreach (var def in Chart)
|
|
||||||
{
|
|
||||||
var tags = new Dictionary<string, object?>();
|
|
||||||
if (def.TagField is not null) tags[def.TagField] = def.TagValue;
|
|
||||||
if (def.Operating) tags["cashFlowCategory"] = "OPERATING";
|
|
||||||
if (!def.Postable) tags["isPostable"] = false; // header accounts (re-applied so older runs are corrected)
|
|
||||||
if (tags.Count > 0) await UpdateAccountAsync(codes[def.Key], tags, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fiscal year + periods for the requested year (default: current year).
|
|
||||||
var fy = year ?? DateTime.Now.Year;
|
|
||||||
var probe = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={fy}-06-30", null, ct);
|
|
||||||
if (!probe.Success)
|
|
||||||
{
|
|
||||||
var fyResult = await gl.SendAsync(HttpMethod.Post, "v1/fiscal-years", new
|
|
||||||
{
|
|
||||||
yearName = $"FY{fy}",
|
|
||||||
startDate = $"{fy}-01-01",
|
|
||||||
endDate = $"{fy}-12-31",
|
|
||||||
autoGeneratePeriods = true,
|
|
||||||
periodCount = 12
|
|
||||||
}, ct);
|
|
||||||
if (!fyResult.Success)
|
|
||||||
throw new InvalidOperationException($"Could not create fiscal year {fy} in GL: {GlJson.Message(fyResult) ?? fyResult.StatusCode.ToString()}");
|
|
||||||
actions.Add($"Created fiscal year FY{fy} with 12 periods");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. GL cash root (GL locks this key after one edit, so only set it when empty).
|
|
||||||
var cashRootId = await AccountIdAsync(codes[GlKeys.CashEquivalents], ct);
|
|
||||||
var cfgResult = await gl.SendAsync(HttpMethod.Get, "v1/system-config", null, ct);
|
|
||||||
var currentRoot = ReadGlConfigValue(cfgResult, "CASH_ACCOUNT_ROOT_ID");
|
|
||||||
if (string.IsNullOrWhiteSpace(currentRoot) && cashRootId is not null)
|
|
||||||
{
|
|
||||||
var set = await gl.SendAsync(HttpMethod.Put, "v1/system-config/CASH_ACCOUNT_ROOT_ID",
|
|
||||||
new { configValue = cashRootId.Value.ToString() }, ct);
|
|
||||||
if (!set.Success)
|
|
||||||
throw new InvalidOperationException($"Could not set CASH_ACCOUNT_ROOT_ID in GL: {GlJson.Message(set) ?? set.StatusCode.ToString()}");
|
|
||||||
actions.Add("Set GL CASH_ACCOUNT_ROOT_ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Persist the mapping, then mirror every existing Distribution cash/bank account.
|
|
||||||
await SaveConfigAsync(codes, userId, ct);
|
|
||||||
|
|
||||||
foreach (var account in await context.BankAccounts.Where(a => a.GlAccountCode == null).ToListAsync(ct))
|
|
||||||
{
|
|
||||||
var code = await EnsureBankAccountAsync(account, ct);
|
|
||||||
if (code is not null) actions.Add($"Mirrored {account.AccountName} as GL account {code}");
|
|
||||||
}
|
|
||||||
|
|
||||||
await SaveConfigAsync(new Dictionary<string, string> { [GlKeys.SetupDone] = "true" }, userId, ct);
|
|
||||||
return new GlSetupResult(true, actions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the GL account code for a Distribution cash/bank account, creating the GL child account on first use.
|
|
||||||
/// Saves the code on the BankAccount. Returns null if GL setup has not been run.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string?> EnsureBankAccountAsync(Models.BankAccount account, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(account.GlAccountCode)) return account.GlAccountCode;
|
|
||||||
|
|
||||||
var parentKey = account.AccountType == BankAccountType.Bank ? GlKeys.BankAccounts : GlKeys.CashInHand;
|
|
||||||
var parentCode = (await configRepo.GetByKeyAsync(parentKey))?.Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(parentCode)) return null;
|
|
||||||
|
|
||||||
var parentId = await AccountIdAsync(parentCode, ct);
|
|
||||||
if (parentId is null) return null;
|
|
||||||
|
|
||||||
var name = account.AccountType == BankAccountType.Bank && !string.IsNullOrWhiteSpace(account.BankName)
|
|
||||||
? $"{account.BankName} - {account.AccountName}"
|
|
||||||
: account.AccountName;
|
|
||||||
|
|
||||||
var created = await CreateAccountAsync(name, Asset, parentId, true, ct);
|
|
||||||
|
|
||||||
account.GlAccountCode = created.Code;
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
context.BankAccounts.Update(account);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
return created.Code;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveConfigAsync(Dictionary<string, string> values, Guid? userId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var (key, value) in values)
|
|
||||||
await configRepo.UpsertAsync(new Models.SysConfig { Key = key, Value = value, UpdatedAt = DateTime.Now, UpdatedBy = userId });
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<long?> AccountIdAsync(string code, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Get, $"v1/accounts/{Uri.EscapeDataString(code)}", null, ct);
|
|
||||||
if (!result.Success) return null;
|
|
||||||
var data = GlJson.Data(result);
|
|
||||||
if (data is null) return null;
|
|
||||||
// GET /accounts/{code} wraps the account: { account: {...}, ... }
|
|
||||||
var account = GlJson.Get(data.Value, "account") ?? data.Value;
|
|
||||||
return GlJson.GetLong(account, "accountId");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(long Id, string Code)> CreateAccountAsync(string name, short typeId, long? parentId, bool postable, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Post, "v1/accounts", new
|
|
||||||
{
|
|
||||||
accountName = name,
|
|
||||||
parentAccountId = parentId,
|
|
||||||
accountTypeId = typeId,
|
|
||||||
isControlAccount = false,
|
|
||||||
isPostable = postable
|
|
||||||
}, ct);
|
|
||||||
if (!result.Success)
|
|
||||||
throw new InvalidOperationException($"Could not create GL account '{name}': {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
|
||||||
|
|
||||||
var data = GlJson.Data(result) ?? throw new InvalidOperationException($"GL returned no data when creating account '{name}'.");
|
|
||||||
var id = GlJson.GetLong(data, "accountId");
|
|
||||||
var code = GlJson.GetString(data, "accountCode");
|
|
||||||
if (id is null || code is null)
|
|
||||||
throw new InvalidOperationException($"GL returned an unexpected body when creating account '{name}'.");
|
|
||||||
return (id.Value, code);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateAccountAsync(string code, Dictionary<string, object?> fields, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Put, $"v1/accounts/{Uri.EscapeDataString(code)}", fields, ct);
|
|
||||||
if (!result.Success)
|
|
||||||
throw new InvalidOperationException($"Could not update GL account {code}: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ReadGlConfigValue(GeneralLedgerResult result, string key)
|
|
||||||
{
|
|
||||||
if (!result.Success || GlJson.Data(result) is not { } data) return null;
|
|
||||||
var items = data.ValueKind == JsonValueKind.Array ? data : GlJson.Get(data, "items");
|
|
||||||
if (items is not { ValueKind: JsonValueKind.Array }) return null;
|
|
||||||
foreach (var item in items.Value.EnumerateArray())
|
|
||||||
if (GlJson.GetString(item, "configKey") == key) return GlJson.GetString(item, "configValue");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.SysConfig;
|
using Distribution.Features.SysConfig;
|
||||||
@@ -14,8 +13,7 @@ public class GrnService(
|
|||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
Distribution.Features.Item.IItemRepository itemRepo,
|
Distribution.Features.Item.IItemRepository itemRepo,
|
||||||
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
||||||
Distribution.Features.SysConfig.SysConfigService sysConfig,
|
Distribution.Features.SysConfig.SysConfigService sysConfig)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
||||||
{
|
{
|
||||||
@@ -484,7 +482,6 @@ public class GrnService(
|
|||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||||
await gl.PostGrnAsync(saved!, ct);
|
|
||||||
return Map(saved!);
|
return Map(saved!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ public interface IInvoiceRepository
|
|||||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
||||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||||
|
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||||
Task AddAsync(Models.Invoice invoice);
|
Task AddAsync(Models.Invoice invoice);
|
||||||
|
Task<Models.Item?> GetItemAsync(Guid itemId);
|
||||||
|
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
|
||||||
|
Task AddVersionAsync(InvoiceVersion version);
|
||||||
|
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
|
||||||
|
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
|
||||||
void Update(Models.Invoice invoice);
|
void Update(Models.Invoice invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,52 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/preview-totals")]
|
||||||
|
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
|
||||||
|
{
|
||||||
|
var result = await service.PreviewTotalsAsync(id, request);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/versions")]
|
||||||
|
public async Task<IActionResult> GetVersions(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetVersionsAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/items")]
|
||||||
|
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.EditedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/print")]
|
[HttpPost("{id:guid}/print")]
|
||||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,48 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Features.ShopPO;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Invoice;
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
|
||||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||||
|
|
||||||
|
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||||
|
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||||
|
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
|
||||||
|
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||||
|
|
||||||
|
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
|
||||||
|
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
|
||||||
|
// Quantity = how many free units the scheme still entitles for the lines as typed.
|
||||||
|
public record FreeEntitlement(Guid SchemeId, int Quantity);
|
||||||
|
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
|
||||||
|
|
||||||
|
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
|
||||||
|
|
||||||
|
public record InvoiceVersionResponse(
|
||||||
|
Guid Id,
|
||||||
|
int VersionNumber,
|
||||||
|
InvoiceVersionStage Stage,
|
||||||
|
decimal TotalAmount,
|
||||||
|
Guid CreatedBy,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceVersionItemResponse> Items);
|
||||||
|
|
||||||
|
public record InvoiceItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid? ShopPOItemId,
|
||||||
|
Guid ItemId,
|
||||||
|
string ItemName,
|
||||||
|
int? OrderedQuantity,
|
||||||
|
int Quantity,
|
||||||
|
decimal UnitPrice,
|
||||||
|
bool IsRemoved,
|
||||||
|
bool IsFree = false,
|
||||||
|
Guid? SchemeId = null,
|
||||||
|
string? SchemeName = null);
|
||||||
|
|
||||||
public record InvoiceResponse(
|
public record InvoiceResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ShopPOId,
|
Guid ShopPOId,
|
||||||
@@ -20,8 +56,11 @@ public record InvoiceResponse(
|
|||||||
DateTime? FinalizedAt,
|
DateTime? FinalizedAt,
|
||||||
Guid? FinalizedBy,
|
Guid? FinalizedBy,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
|
decimal DiscountAmount,
|
||||||
decimal PaidAmount,
|
decimal PaidAmount,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceItemResponse> Items,
|
||||||
|
List<ShopPODiscountResponse>? Discounts = null);
|
||||||
|
|
||||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
await context.Invoices
|
await context.Invoices
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||||
|
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
|
||||||
|
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
|
||||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||||
|
|
||||||
@@ -49,9 +51,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
|
||||||
|
await context.SchemeRedemptions
|
||||||
|
.Where(r => r.FK_ShopPOId == shopPOId)
|
||||||
|
.SumAsync(r => r.DiscountAmount) ?? 0m;
|
||||||
|
|
||||||
public async Task AddAsync(Models.Invoice invoice) =>
|
public async Task AddAsync(Models.Invoice invoice) =>
|
||||||
await context.Invoices.AddAsync(invoice);
|
await context.Invoices.AddAsync(invoice);
|
||||||
|
|
||||||
|
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
|
||||||
|
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
|
||||||
|
|
||||||
|
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
|
||||||
|
await context.InvoiceItems.AddRangeAsync(items);
|
||||||
|
|
||||||
|
public async Task AddVersionAsync(InvoiceVersion version) =>
|
||||||
|
await context.InvoiceVersions.AddAsync(version);
|
||||||
|
|
||||||
|
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
|
||||||
|
await context.InvoiceVersions
|
||||||
|
.Where(v => v.FK_InvoiceId == invoiceId)
|
||||||
|
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
|
||||||
|
|
||||||
|
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
|
||||||
|
await context.InvoiceVersions
|
||||||
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
|
||||||
|
.OrderBy(v => v.VersionNumber)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
public void Update(Models.Invoice invoice) =>
|
public void Update(Models.Invoice invoice) =>
|
||||||
context.Invoices.Update(invoice);
|
context.Invoices.Update(invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.GeneralLedger;
|
using Distribution.Features.Scheme;
|
||||||
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -8,9 +9,10 @@ namespace Distribution.Features.Invoice;
|
|||||||
|
|
||||||
public class InvoiceService(
|
public class InvoiceService(
|
||||||
IInvoiceRepository repo,
|
IInvoiceRepository repo,
|
||||||
|
IShopPORepository shopPoRepo,
|
||||||
|
SchemeService schemeService,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||||
new(i.Id, i.FK_ShopPOId,
|
new(i.Id, i.FK_ShopPOId,
|
||||||
@@ -19,14 +21,114 @@ public class InvoiceService(
|
|||||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||||
i.PrintedAt, i.PrintedBy,
|
i.PrintedAt, i.PrintedBy,
|
||||||
i.FinalizedAt, i.FinalizedBy,
|
i.FinalizedAt, i.FinalizedBy,
|
||||||
i.TotalAmount,
|
i.TotalAmount, i.DiscountAmount,
|
||||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
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)
|
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(id);
|
var invoice = await repo.GetByIdAsync(id);
|
||||||
return invoice is null ? null : ToResponse(invoice);
|
if (invoice is null) return null;
|
||||||
|
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
||||||
@@ -44,6 +146,131 @@ public class InvoiceService(
|
|||||||
return new OutstandingPaymentsResponse(count, outstanding);
|
return new OutstandingPaymentsResponse(count, outstanding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
||||||
|
?? throw new KeyNotFoundException("Shop PO not found");
|
||||||
|
|
||||||
|
if (po.ConfirmedAt is null)
|
||||||
|
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
||||||
|
if (po.Status == ShopPOStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
||||||
|
if (po.InvoiceId is not null)
|
||||||
|
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
||||||
|
|
||||||
|
var invoice = new Models.Invoice
|
||||||
|
{
|
||||||
|
FK_ShopPOId = po.Id,
|
||||||
|
Status = InvoiceStatus.Draft,
|
||||||
|
PaymentStatus = PaymentStatus.Pending,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||||
|
await ApplyTotalsAsync(invoice, lines);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(invoice);
|
||||||
|
await repo.AddItemsAsync(lines);
|
||||||
|
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
||||||
|
po.InvoiceId = invoice.Id;
|
||||||
|
shopPoRepo.Update(po);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(invoice.Id);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Invoice not found");
|
||||||
|
|
||||||
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
|
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
|
||||||
|
|
||||||
|
var newLines = new List<InvoiceItem>();
|
||||||
|
|
||||||
|
foreach (var edit in request.Items)
|
||||||
|
{
|
||||||
|
if (edit.UnitPrice is < 0)
|
||||||
|
throw new InvalidOperationException("Unit price cannot be negative");
|
||||||
|
|
||||||
|
if (edit.InvoiceItemId is Guid lineId)
|
||||||
|
{
|
||||||
|
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
|
||||||
|
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
|
||||||
|
|
||||||
|
item.IsRemoved = edit.IsRemoved;
|
||||||
|
if (!edit.IsRemoved)
|
||||||
|
{
|
||||||
|
ValidateQuantity(item, edit.Quantity);
|
||||||
|
item.Quantity = edit.Quantity;
|
||||||
|
}
|
||||||
|
if (edit.UnitPrice is decimal price)
|
||||||
|
{
|
||||||
|
if (item.IsFree && price != 0m)
|
||||||
|
throw new InvalidOperationException("A free-issue line must stay at zero price");
|
||||||
|
item.UnitPrice = price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (edit.ItemId is Guid itemId)
|
||||||
|
{
|
||||||
|
// New line for an item that was not on the PO.
|
||||||
|
if (edit.IsRemoved) continue;
|
||||||
|
if (edit.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
|
||||||
|
if (edit.UnitPrice is null)
|
||||||
|
throw new InvalidOperationException("Unit price is required for an added item");
|
||||||
|
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
|
||||||
|
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
|
||||||
|
|
||||||
|
var catalogItem = await repo.GetItemAsync(itemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {itemId} not found");
|
||||||
|
|
||||||
|
newLines.Add(new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoice.Id,
|
||||||
|
FK_ItemId = catalogItem.Id,
|
||||||
|
Item = catalogItem,
|
||||||
|
Quantity = edit.Quantity,
|
||||||
|
UnitPrice = edit.UnitPrice.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var allLines = invoice.Items.Concat(newLines).ToList();
|
||||||
|
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
||||||
|
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
||||||
|
await ApplyTotalsAsync(invoice, allLines);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return (await GetByIdAsync(id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||||
|
{
|
||||||
|
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
|
||||||
|
if (quantity < 0 || quantity > ordered)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Quantity for item {item.Id} must be between 0 and {ordered}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(id)
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
@@ -52,12 +279,21 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Draft)
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
||||||
|
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
|
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Printed;
|
invoice.Status = InvoiceStatus.Printed;
|
||||||
invoice.PrintedAt = DateTime.Now;
|
invoice.PrintedAt = DateTime.Now;
|
||||||
invoice.PrintedBy = printedBy;
|
invoice.PrintedBy = printedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
@@ -71,35 +307,39 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Printed)
|
if (invoice.Status != InvoiceStatus.Printed)
|
||||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
var adjustments = request.Adjustments ?? [];
|
var adjustments = request.Adjustments ?? [];
|
||||||
if (adjustments.Count > 0)
|
|
||||||
|
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
||||||
|
foreach (var adjustment in adjustments)
|
||||||
{
|
{
|
||||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||||
decimal total = 0;
|
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||||
foreach (var item in poItems)
|
|
||||||
|
item.IsRemoved = adjustment.IsRemoved;
|
||||||
|
if (!adjustment.IsRemoved)
|
||||||
{
|
{
|
||||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
item.Quantity = adjustment.AdjustedQuantity;
|
||||||
|
|
||||||
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
|
||||||
|
|
||||||
total += billedQuantity * item.UnitPrice;
|
|
||||||
}
|
}
|
||||||
invoice.TotalAmount = total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
await ApplyTotalsAsync(invoice, items);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Finalized;
|
invoice.Status = InvoiceStatus.Finalized;
|
||||||
invoice.FinalizedAt = DateTime.Now;
|
invoice.FinalizedAt = DateTime.Now;
|
||||||
invoice.FinalizedBy = finalizedBy;
|
invoice.FinalizedBy = finalizedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
await gl.PostInvoiceFinalizedAsync(invoice, ct);
|
|
||||||
|
|
||||||
return ToResponse(invoice);
|
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;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Features.Invoice;
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.ReceivedCheque;
|
using Distribution.Features.ReceivedCheque;
|
||||||
using Distribution.Features.StoreCredit;
|
using Distribution.Features.StoreCredit;
|
||||||
@@ -16,8 +15,7 @@ public class PaymentService(
|
|||||||
IReceivedChequeRepository receivedChequeRepo,
|
IReceivedChequeRepository receivedChequeRepo,
|
||||||
IStoreCreditMovementRepository creditRepo,
|
IStoreCreditMovementRepository creditRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||||
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
||||||
@@ -152,13 +150,14 @@ public class PaymentService(
|
|||||||
|
|
||||||
invoiceRepo.Update(invoice);
|
invoiceRepo.Update(invoice);
|
||||||
|
|
||||||
|
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||||
|
// Debit Cash/Bank (BankAccount) OR Cheques-in-hand (for Cheque payments)
|
||||||
|
// Credit Accounts Receivable (invoice.Store)
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
// Mirrors to the General Ledger after commit: Dr Cash/Bank (or Cheques-in-hand) / Cr Receivable. Never throws.
|
|
||||||
await gl.PostCustomerPaymentAsync(payment, ct);
|
|
||||||
|
|
||||||
return ToResponse(payment);
|
return ToResponse(payment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public interface IPaymentReportRepository
|
||||||
|
{
|
||||||
|
/// <summary>Active payments collected within [From, To] (whole days).</summary>
|
||||||
|
Task<List<PaymentRow>> GetPaymentsAsync(PaymentReportFilter f);
|
||||||
|
|
||||||
|
/// <summary>Finalized, non-cancelled invoices up to the end of 'To', each with cash/cheque paid as of then.</summary>
|
||||||
|
Task<List<InvoiceBalanceRow>> GetInvoiceBalancesAsync(PaymentReportFilter f);
|
||||||
|
|
||||||
|
/// <summary>Sum of TotalAmount of invoices finalized within [From, To].</summary>
|
||||||
|
Task<decimal> GetInvoicedAsync(PaymentReportFilter f);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports/payments")]
|
||||||
|
public class PaymentReportController(PaymentReportService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("analytics")]
|
||||||
|
public async Task<IActionResult> Analytics(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] Guid? regionId)
|
||||||
|
{
|
||||||
|
var f = PaymentReportService.BuildFilter(from, to, regionId);
|
||||||
|
var error = PaymentReportService.Validate(f);
|
||||||
|
return error is null ? Ok(await service.GetAnalyticsAsync(f)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public record PaymentReportFilter(DateTime From, DateTime To, Guid? RegionId);
|
||||||
|
|
||||||
|
public record PaymentSummary(
|
||||||
|
decimal Invoiced,
|
||||||
|
decimal Collected,
|
||||||
|
decimal CreditExtended,
|
||||||
|
decimal Outstanding,
|
||||||
|
decimal Overdue,
|
||||||
|
decimal? CollectionRatePct,
|
||||||
|
int PaymentCount);
|
||||||
|
|
||||||
|
public record MethodBreakdown(string Method, int Count, decimal Amount, decimal SharePct);
|
||||||
|
|
||||||
|
public record PaymentTrendPoint(string Period, decimal Cash, decimal Cheque, decimal Credit);
|
||||||
|
|
||||||
|
public record AgingBucket(string Bucket, int Invoices, decimal Balance);
|
||||||
|
|
||||||
|
public record StoreOutstanding(Guid StoreId, string StoreName, int Invoices, decimal Balance, decimal Overdue);
|
||||||
|
|
||||||
|
public record AccountCollection(Guid? AccountId, string AccountName, int Count, decimal Amount);
|
||||||
|
|
||||||
|
public record ChequePipeline(string Status, int Count, decimal Amount);
|
||||||
|
|
||||||
|
public record PaymentAnalyticsResponse(
|
||||||
|
DateTime From,
|
||||||
|
DateTime To,
|
||||||
|
PaymentSummary Summary,
|
||||||
|
List<MethodBreakdown> ByMethod,
|
||||||
|
List<PaymentTrendPoint> Trend,
|
||||||
|
List<AgingBucket> Aging,
|
||||||
|
List<StoreOutstanding> TopOutstanding,
|
||||||
|
List<AccountCollection> ByAccount,
|
||||||
|
List<ChequePipeline> Cheques);
|
||||||
|
|
||||||
|
// Repository row shapes
|
||||||
|
public record PaymentRow(PaymentMethod Method, decimal Amount, DateTime CollectedAt, Guid? AccountId, string? AccountName, string? ChequeStatus);
|
||||||
|
|
||||||
|
public record InvoiceBalanceRow(Guid StoreId, string StoreName, DateTime FinalizedAt, decimal Total, decimal Paid);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public class PaymentReportRepository(AppDBContext db) : IPaymentReportRepository
|
||||||
|
{
|
||||||
|
public async Task<List<PaymentRow>> GetPaymentsAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Payments.AsNoTracking()
|
||||||
|
.Where(p => p.IsActive && p.CollectedAt >= start && p.CollectedAt < end
|
||||||
|
&& p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
q = q.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
|
||||||
|
return await q.Select(p => new PaymentRow(
|
||||||
|
p.Method, p.Amount, p.CollectedAt,
|
||||||
|
p.FK_BankAccountId,
|
||||||
|
p.BankAccount == null ? null : p.BankAccount.AccountName,
|
||||||
|
p.ReceivedCheque == null ? null : p.ReceivedCheque.Status.ToString()))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<InvoiceBalanceRow>> GetInvoiceBalancesAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Invoices.AsNoTracking()
|
||||||
|
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||||
|
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
|
||||||
|
// Credit payments do not reduce the receivable (same rule as the dashboard).
|
||||||
|
return await q.Select(i => new InvoiceBalanceRow(
|
||||||
|
i.ShopPO.Store.Id,
|
||||||
|
i.ShopPO.Store.StoreName,
|
||||||
|
i.FinalizedAt!.Value,
|
||||||
|
i.TotalAmount,
|
||||||
|
i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||||
|
.Sum(p => (decimal?)p.Amount) ?? 0m))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetInvoicedAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Invoices.AsNoTracking()
|
||||||
|
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||||
|
&& i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
return await q.SumAsync(i => (decimal?)i.TotalAmount) ?? 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public class PaymentReportService(IPaymentReportRepository repo)
|
||||||
|
{
|
||||||
|
public const int MaxRangeDays = 366;
|
||||||
|
private const int DailyGranularityMaxDays = 92;
|
||||||
|
private const int OverdueDays = 30; // matches the dashboard receivables definition
|
||||||
|
private const int TopStores = 10;
|
||||||
|
|
||||||
|
public static PaymentReportFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId)
|
||||||
|
{
|
||||||
|
var end = (to ?? DateTime.Now).Date;
|
||||||
|
var start = (from ?? end.AddDays(-29)).Date;
|
||||||
|
return new PaymentReportFilter(start, end, regionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Validate(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
if (f.To < f.From) return "'to' must not be before 'from'.";
|
||||||
|
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal Pct(decimal part, decimal whole) => whole > 0 ? Math.Round(part / whole * 100, 2) : 0m;
|
||||||
|
|
||||||
|
public async Task<PaymentAnalyticsResponse> GetAnalyticsAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var payments = await repo.GetPaymentsAsync(f);
|
||||||
|
var balances = await repo.GetInvoiceBalancesAsync(f);
|
||||||
|
var invoiced = await repo.GetInvoicedAsync(f);
|
||||||
|
var asOf = f.To.Date.AddDays(1);
|
||||||
|
|
||||||
|
var collected = payments.Where(p => p.Method != PaymentMethod.Credit).Sum(p => p.Amount);
|
||||||
|
var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount);
|
||||||
|
var total = payments.Sum(p => p.Amount);
|
||||||
|
|
||||||
|
var open = balances
|
||||||
|
.Select(b => (b.StoreId, b.StoreName, Age: (asOf - b.FinalizedAt).TotalDays, Balance: Math.Max(0m, b.Total - b.Paid)))
|
||||||
|
.Where(b => b.Balance > 0)
|
||||||
|
.ToList();
|
||||||
|
var outstanding = open.Sum(b => b.Balance);
|
||||||
|
var overdue = open.Where(b => b.Age > OverdueDays).Sum(b => b.Balance);
|
||||||
|
|
||||||
|
var summary = new PaymentSummary(
|
||||||
|
invoiced, collected, credit, outstanding, overdue,
|
||||||
|
invoiced > 0 ? Math.Round(collected / invoiced * 100, 2) : null,
|
||||||
|
payments.Count);
|
||||||
|
|
||||||
|
var byMethod = Enum.GetValues<PaymentMethod>().Select(m =>
|
||||||
|
{
|
||||||
|
var rows = payments.Where(p => p.Method == m).ToList();
|
||||||
|
var amount = rows.Sum(p => p.Amount);
|
||||||
|
return new MethodBreakdown(m.ToString(), rows.Count, amount, Pct(amount, total));
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var daily = (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
|
||||||
|
var trend = payments
|
||||||
|
.GroupBy(p => daily ? p.CollectedAt.ToString("yyyy-MM-dd") : p.CollectedAt.ToString("yyyy-MM"))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => new PaymentTrendPoint(g.Key,
|
||||||
|
g.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount),
|
||||||
|
g.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount),
|
||||||
|
g.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var buckets = new (string Name, Func<double, bool> In)[]
|
||||||
|
{
|
||||||
|
("0-30 days", a => a <= 30),
|
||||||
|
("31-60 days", a => a > 30 && a <= 60),
|
||||||
|
("61-90 days", a => a > 60 && a <= 90),
|
||||||
|
("90+ days", a => a > 90),
|
||||||
|
};
|
||||||
|
var aging = buckets.Select(b =>
|
||||||
|
{
|
||||||
|
var rows = open.Where(o => b.In(o.Age)).ToList();
|
||||||
|
return new AgingBucket(b.Name, rows.Count, rows.Sum(r => r.Balance));
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var topOutstanding = open
|
||||||
|
.GroupBy(o => (o.StoreId, o.StoreName))
|
||||||
|
.Select(g => new StoreOutstanding(g.Key.StoreId, g.Key.StoreName, g.Count(),
|
||||||
|
g.Sum(x => x.Balance), g.Where(x => x.Age > OverdueDays).Sum(x => x.Balance)))
|
||||||
|
.OrderByDescending(s => s.Balance)
|
||||||
|
.Take(TopStores)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var byAccount = payments
|
||||||
|
.Where(p => p.Method == PaymentMethod.Cash)
|
||||||
|
.GroupBy(p => (p.AccountId, Name: p.AccountName ?? "Unassigned"))
|
||||||
|
.Select(g => new AccountCollection(g.Key.AccountId, g.Key.Name, g.Count(), g.Sum(p => p.Amount)))
|
||||||
|
.OrderByDescending(a => a.Amount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var cheques = payments
|
||||||
|
.Where(p => p.Method == PaymentMethod.Cheque)
|
||||||
|
.GroupBy(p => p.ChequeStatus ?? "Unknown")
|
||||||
|
.Select(g => new ChequePipeline(g.Key, g.Count(), g.Sum(p => p.Amount)))
|
||||||
|
.OrderByDescending(c => c.Amount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PaymentAnalyticsResponse(f.From, f.To, summary, byMethod, trend, aging, topOutstanding, byAccount, cheques);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
return (rows, total);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||||
{
|
{
|
||||||
var lines =
|
var lines =
|
||||||
from li in db.ShopPOItems.AsNoTracking()
|
from li in db.InvoiceItems.AsNoTracking()
|
||||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
where li.IsActive && !li.IsRemoved
|
||||||
where inv.Status == InvoiceStatus.Finalized
|
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||||
select li;
|
select li;
|
||||||
|
|
||||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
if (f.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
if (f.RouteId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||||
{
|
{
|
||||||
var s = f.Search.Trim();
|
var s = f.Search.Trim();
|
||||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||||
|
|
||||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
// Distinct orders and stores per item are counted separately: MySQL can't run the correlated
|
||||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||||
var storePairs = await SoldLines(f)
|
var pairs = await SoldLines(f)
|
||||||
.Where(l => ids.Contains(l.FK_ItemId))
|
.Where(l => ids.Contains(l.FK_ItemId))
|
||||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
var orderCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(p => p.PoId).Distinct().Count());
|
||||||
|
var storeCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(p => p.FK_StoreId).Distinct().Count());
|
||||||
|
|
||||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
|
||||||
|
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||||
return (rows, total);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
SoldLines(f).SumAsync(x => x.Quantity * x.UnitPrice);
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||||
{
|
{
|
||||||
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
return await SoldLines(f with { Search = null })
|
return await SoldLines(f with { Search = null })
|
||||||
.Where(l => ids.Contains(l.FK_ItemId))
|
.Where(l => ids.Contains(l.FK_ItemId))
|
||||||
.GroupBy(l => l.FK_ItemId)
|
.GroupBy(l => l.FK_ItemId)
|
||||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.Quantity) })
|
||||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.ChequeBook;
|
using Distribution.Features.ChequeBook;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
@@ -9,8 +8,7 @@ namespace Distribution.Features.ReceivedCheque;
|
|||||||
public class ReceivedChequeService(
|
public class ReceivedChequeService(
|
||||||
IReceivedChequeRepository repo,
|
IReceivedChequeRepository repo,
|
||||||
IBankAccountRepository bankAccountRepo,
|
IBankAccountRepository bankAccountRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
||||||
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
||||||
@@ -112,7 +110,6 @@ public class ReceivedChequeService(
|
|||||||
|
|
||||||
repo.Update(cheque);
|
repo.Update(cheque);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
await gl.PostReceivedChequeAsync(cheque, request.Action, ct);
|
|
||||||
return ToResponse(cheque);
|
return ToResponse(cheque);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.DeliveryRide;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -10,9 +12,14 @@ public class RideService(
|
|||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IDriverProfileRepository driverRepo,
|
IDriverProfileRepository driverRepo,
|
||||||
IDriverComplianceConfigRepository complianceRepo,
|
IDriverComplianceConfigRepository complianceRepo,
|
||||||
|
IDeliveryTaskRepository taskRepo,
|
||||||
StockMover mover,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
|
ILogger<RideService> logger,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string RidesLink = "/operations/rides";
|
||||||
|
|
||||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||||
{
|
{
|
||||||
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||||
@@ -72,6 +79,12 @@ public class RideService(
|
|||||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.RideDriverAssigned,
|
||||||
|
[RecipientRule.Users(driver.FK_UserId)],
|
||||||
|
new NotificationArgs(null, "Ride", rideId, RidesLink),
|
||||||
|
ct);
|
||||||
|
|
||||||
var saved = await rideRepo.GetByIdAsync(rideId);
|
var saved = await rideRepo.GetByIdAsync(rideId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -125,9 +138,46 @@ public class RideService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyRideCancelledAsync(ride, request, userId, ct);
|
||||||
|
|
||||||
return ToResponse(ride);
|
return ToResponse(ride);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A ride has no route of its own; the people to tell are the owners and managers of the routes
|
||||||
|
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
|
||||||
|
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
|
||||||
|
var routeIds = tasks
|
||||||
|
.Where(t => t.ShopPO?.Store is not null)
|
||||||
|
.Select(t => t.ShopPO.Store.FK_RouteId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var recipients = new List<RecipientRule>();
|
||||||
|
foreach (var routeId in routeIds)
|
||||||
|
{
|
||||||
|
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
|
||||||
|
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
|
||||||
|
}
|
||||||
|
if (recipients.Count == 0) return;
|
||||||
|
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.RideCancelled,
|
||||||
|
recipients,
|
||||||
|
new NotificationArgs(
|
||||||
|
new { CancelType = request.TypeCode, Reason = request.Reason },
|
||||||
|
"Ride", ride.Id, RidesLink, userId),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||||
{
|
{
|
||||||
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public interface ISchemeRepository
|
|||||||
|
|
||||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||||
|
Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId);
|
||||||
|
void RemoveRedemption(Models.SchemeRedemption entity);
|
||||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||||
|
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||||
|
|
||||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ public class SchemeController(SchemeService service) : ControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("suggest")]
|
||||||
|
[Authorize("scheme.view")]
|
||||||
|
public async Task<IActionResult> Suggest(SuggestSchemesRequest request)
|
||||||
|
{
|
||||||
|
var result = await service.SuggestAsync(request);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/spend")]
|
[HttpGet("{id:guid}/spend")]
|
||||||
[Authorize("scheme.view")]
|
[Authorize("scheme.view")]
|
||||||
public async Task<IActionResult> GetSpend(Guid id)
|
public async Task<IActionResult> GetSpend(Guid id)
|
||||||
|
|||||||
@@ -94,7 +94,13 @@ public record SchemeEligibilityResponse(
|
|||||||
string? Reason,
|
string? Reason,
|
||||||
decimal? DiscountAmount,
|
decimal? DiscountAmount,
|
||||||
Guid? FreeItemId,
|
Guid? FreeItemId,
|
||||||
decimal? FreeQty);
|
decimal? FreeQty,
|
||||||
|
string? FreeItemName = null);
|
||||||
|
|
||||||
|
/// <summary>A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list.</summary>
|
||||||
|
public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false);
|
||||||
|
|
||||||
|
public record SuggestSchemesRequest(Guid StoreId, List<SuggestLine> Items);
|
||||||
|
|
||||||
public record SchemeRedemptionResponse(
|
public record SchemeRedemptionResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -49,9 +49,25 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
|||||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||||
await context.SchemeRedemptions.AddAsync(entity);
|
await context.SchemeRedemptions.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId)
|
||||||
|
{
|
||||||
|
var list = await context.SchemeRedemptions.Include(r => r.Scheme).Where(r => r.FK_ShopPOId == shopPOId).ToListAsync();
|
||||||
|
// Rows added earlier in this unit of work are not in the database yet; rows removed are still there.
|
||||||
|
foreach (var added in context.SchemeRedemptions.Local.Where(r => r.FK_ShopPOId == shopPOId && !list.Contains(r)))
|
||||||
|
list.Add(added);
|
||||||
|
return list.Where(r => context.Entry(r).State != EntityState.Deleted).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveRedemption(Models.SchemeRedemption entity) =>
|
||||||
|
context.SchemeRedemptions.Remove(entity);
|
||||||
|
|
||||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||||
|
|
||||||
|
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
|
||||||
|
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
|
||||||
|
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
|
||||||
|
|
||||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||||
await context.SchemeBundleComponents
|
await context.SchemeBundleComponents
|
||||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
@@ -14,6 +16,8 @@ public class SchemeService(
|
|||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStoreRepository storeRepo,
|
IStoreRepository storeRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
|
IInvoiceRepository invoiceRepo,
|
||||||
|
PriceListService priceListService,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
@@ -261,12 +265,7 @@ public class SchemeService(
|
|||||||
|
|
||||||
foreach (var scheme in candidates)
|
foreach (var scheme in candidates)
|
||||||
{
|
{
|
||||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||||
continue;
|
|
||||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
|
||||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
result.Add(EvaluateRule(scheme, items));
|
result.Add(EvaluateRule(scheme, items));
|
||||||
@@ -275,6 +274,328 @@ public class SchemeService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsInScope(Models.Scheme scheme, string? storeSegment, Guid? storeRegionId, DateTime asOf)
|
||||||
|
{
|
||||||
|
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != storeRegionId) return false;
|
||||||
|
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||||
|
!string.Equals(scheme.EligibleSegment, storeSegment, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
return asOf >= scheme.DateRangeFrom && asOf.Date <= scheme.DateRangeTo.Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggestions for a Shop PO that is still being built (nothing saved yet). Returns eligible schemes,
|
||||||
|
/// plus BuyXGetY/FreeBundle schemes the cart is part-way to, so the UI can nudge ("add 4 more").
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<SchemeEligibilityResponse>?> SuggestAsync(SuggestSchemesRequest request)
|
||||||
|
{
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||||
|
if (store is null || !store.IsActive) return null;
|
||||||
|
|
||||||
|
var lines = new List<ShopPOItem>();
|
||||||
|
foreach (var l in request.Items.Where(i => !i.IsFree))
|
||||||
|
{
|
||||||
|
var price = l.UnitPrice
|
||||||
|
?? (await priceListService.ResolveByStoreAsync(l.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||||
|
?? 0m;
|
||||||
|
lines.Add(new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = price });
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionId = store.Route?.FK_RegionId;
|
||||||
|
var inCart = lines.Select(l => l.FK_ItemId).ToHashSet();
|
||||||
|
var result = new List<SchemeEligibilityResponse>();
|
||||||
|
|
||||||
|
foreach (var scheme in (await repo.GetAllAsync()).Where(s => s.IsActive))
|
||||||
|
{
|
||||||
|
if (!IsInScope(scheme, store.Segment, regionId, DateTime.Now)) continue;
|
||||||
|
|
||||||
|
var eval = EvaluateRule(scheme, lines);
|
||||||
|
var relevant = eval.Eligible || scheme.RuleType switch
|
||||||
|
{
|
||||||
|
SchemeRuleType.BuyXGetY => scheme.FK_TriggerItemId.HasValue && inCart.Contains(scheme.FK_TriggerItemId.Value),
|
||||||
|
SchemeRuleType.FreeBundle => scheme.BundleComponents.Any(c => inCart.Contains(c.FK_ItemId)),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
if (!relevant) continue;
|
||||||
|
|
||||||
|
result.Add(eval with { FreeItemName = scheme.FreeItem?.Name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the free lines of a Shop PO that is about to be saved: each must reference an in-scope scheme the
|
||||||
|
/// paid lines still qualify for, be the scheme's free item, stay within the entitled qty, and be in stock.
|
||||||
|
/// Stamps the scheme-name snapshot and forces UnitPrice to 0. Read-only; throws InvalidOperationException.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines)
|
||||||
|
{
|
||||||
|
var freeLines = lines.Where(l => l.IsFree).ToList();
|
||||||
|
if (freeLines.Count == 0) return;
|
||||||
|
|
||||||
|
if (freeLines.Any(l => l.FK_SchemeId is null))
|
||||||
|
throw new InvalidOperationException("A free-issue line must reference the scheme that granted it.");
|
||||||
|
if (freeLines.Any(l => l.Quantity <= 0))
|
||||||
|
throw new InvalidOperationException("Free-issue quantity must be greater than zero (remove the line instead).");
|
||||||
|
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||||
|
?? throw new InvalidOperationException("Store not found.");
|
||||||
|
var regionId = store.Route?.FK_RegionId;
|
||||||
|
|
||||||
|
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(group.Key);
|
||||||
|
if (scheme is null || !scheme.IsActive)
|
||||||
|
throw new InvalidOperationException("A free-issue line references a scheme that no longer exists.");
|
||||||
|
if (!IsInScope(scheme, store.Segment, regionId, asOf))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||||
|
|
||||||
|
var eval = EvaluateRule(scheme, lines);
|
||||||
|
if (!eval.Eligible || eval.FreeItemId is null || eval.FreeQty is null)
|
||||||
|
throw new InvalidOperationException(eval.Reason ?? $"Order no longer qualifies for scheme '{scheme.Name}'.");
|
||||||
|
if (group.Any(l => l.FK_ItemId != eval.FreeItemId))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' grants a different free item.");
|
||||||
|
|
||||||
|
var qty = group.Sum(l => l.Quantity);
|
||||||
|
if (qty > eval.FreeQty.Value)
|
||||||
|
throw new InvalidOperationException($"Free quantity {qty} exceeds the {eval.FreeQty.Value:0.##} entitled by scheme '{scheme.Name}'.");
|
||||||
|
|
||||||
|
var available = (await stockRepo.GetBalancesByItemAsync(eval.FreeItemId.Value)).Sum(b => b.Quantity);
|
||||||
|
if (available < qty)
|
||||||
|
throw new InvalidOperationException($"Insufficient stock to give {qty} free of scheme '{scheme.Name}'.");
|
||||||
|
|
||||||
|
foreach (var l in group)
|
||||||
|
{
|
||||||
|
l.UnitPrice = 0m;
|
||||||
|
l.SchemeName = scheme.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the percentage-discount schemes (FlatPercent / SlabDiscount) the user accepted for a Shop PO that is
|
||||||
|
/// about to be saved: one at most, in scope, and the paid lines must still qualify. Read-only; throws.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines, List<Guid>? schemeIds)
|
||||||
|
{
|
||||||
|
if (schemeIds is not { Count: > 0 }) return;
|
||||||
|
if (schemeIds.Distinct().Count() > 1)
|
||||||
|
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||||
|
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||||
|
?? throw new InvalidOperationException("Store not found.");
|
||||||
|
|
||||||
|
var scheme = await repo.GetByIdAsync(schemeIds[0]);
|
||||||
|
if (scheme is null || !scheme.IsActive)
|
||||||
|
throw new InvalidOperationException("The selected discount scheme no longer exists.");
|
||||||
|
if (scheme.RuleType is not (SchemeRuleType.FlatPercent or SchemeRuleType.SlabDiscount))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not a discount scheme.");
|
||||||
|
if (!IsInScope(scheme, store.Segment, store.Route?.FK_RegionId, asOf))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||||
|
|
||||||
|
var eval = EvaluateRule(scheme, lines);
|
||||||
|
if (!eval.Eligible)
|
||||||
|
throw new InvalidOperationException(eval.Reason ?? $"Order does not qualify for scheme '{scheme.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-evaluates the percentage discounts redeemed against a Shop PO for the given (invoice) lines and returns
|
||||||
|
/// their total. With persist = true the redemption amounts are updated too (the caller commits), so spend/ROI and
|
||||||
|
/// the invoice stay in step; a discount the lines no longer qualify for drops to 0 and comes back if they requalify.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<decimal> CalculateDiscountAsync(Guid shopPOId, IEnumerable<ShopPOItem> lines, bool persist)
|
||||||
|
{
|
||||||
|
var priced = lines.ToList();
|
||||||
|
decimal total = 0m;
|
||||||
|
foreach (var r in (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue))
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||||
|
var amount = eval is { Eligible: true, DiscountAmount: not null } ? eval.DiscountAmount.Value : 0m;
|
||||||
|
if (persist) r.DiscountAmount = amount;
|
||||||
|
total += amount;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many free units each scheme currently entitles for these paid lines (0 when no longer eligible).
|
||||||
|
private async Task<Dictionary<Guid, int>> FreeEntitlementsAsync(IEnumerable<Guid> schemeIds, List<ShopPOItem> paidLines)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<Guid, int>();
|
||||||
|
foreach (var id in schemeIds.Distinct())
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(id);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, paidLines);
|
||||||
|
result[id] = eval is { Eligible: true, FreeQty: not null } ? (int)Math.Floor(eval.FreeQty.Value) : 0;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Free-issue entitlement per scheme for unsaved invoice lines (editor preview). Lines carry IsFree / scheme id;
|
||||||
|
/// only the paid ones count toward the scheme rules.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Dictionary<Guid, int>> GetFreeEntitlementsAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines) =>
|
||||||
|
await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps an invoice's free-issue lines within what the remaining paid lines still earn: when the paid quantity of the
|
||||||
|
/// qualifying item is lowered (or removed), the free quantity is reduced to match (a line that earns nothing is struck
|
||||||
|
/// out) and the PO's redemption is resized. Stages changes only. Returns true if any free line was changed.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List<InvoiceItem> lines)
|
||||||
|
{
|
||||||
|
var freeLines = lines.Where(l => l.IsActive && l.IsFree && l.FK_SchemeId.HasValue).ToList();
|
||||||
|
if (freeLines.Count == 0) return false;
|
||||||
|
|
||||||
|
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||||
|
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||||
|
.ToList();
|
||||||
|
var entitled = await FreeEntitlementsAsync(freeLines.Select(l => l.FK_SchemeId!.Value), paid);
|
||||||
|
var redemptions = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||||
|
{
|
||||||
|
var remaining = entitled[group.Key];
|
||||||
|
foreach (var line in group.Where(l => !l.IsRemoved).OrderBy(l => l.CreatedAt))
|
||||||
|
{
|
||||||
|
if (line.Quantity <= remaining) { remaining -= line.Quantity; continue; }
|
||||||
|
|
||||||
|
if (remaining == 0) line.IsRemoved = true;
|
||||||
|
else line.Quantity = remaining;
|
||||||
|
remaining = 0;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var redemption = redemptions.FirstOrDefault(r => r.FK_SchemeId == group.Key && r.FK_FreeItemId.HasValue);
|
||||||
|
if (redemption is not null)
|
||||||
|
redemption.FreeQty = group.Where(l => !l.IsRemoved).Sum(l => l.Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discount the given schemes would give for these lines, without touching any redemption (editor preview).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<decimal> CalculateDiscountForSchemesAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines)
|
||||||
|
{
|
||||||
|
var priced = lines.ToList();
|
||||||
|
decimal total = 0m;
|
||||||
|
foreach (var id in schemeIds.Distinct())
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(id);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||||
|
if (eval is { Eligible: true, DiscountAmount: not null }) total += eval.DiscountAmount.Value;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the discount choice made on a Draft invoice (the percentage scheme the user accepted, or none) to the
|
||||||
|
/// PO's redemptions: newly chosen schemes are validated and added, unchosen ones removed. The amount itself is
|
||||||
|
/// then set by CalculateDiscountAsync when the totals are re-derived. Stages changes only. Null = leave as is.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List<InvoiceItem> lines, List<Guid>? schemeIds)
|
||||||
|
{
|
||||||
|
if (schemeIds is null) return;
|
||||||
|
if (schemeIds.Distinct().Count() > 1)
|
||||||
|
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||||
|
|
||||||
|
var existing = (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue).ToList();
|
||||||
|
foreach (var r in existing.Where(r => !schemeIds.Contains(r.FK_SchemeId)))
|
||||||
|
repo.RemoveRedemption(r);
|
||||||
|
|
||||||
|
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||||
|
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||||
|
.ToList();
|
||||||
|
var added = schemeIds.Where(id => existing.All(r => r.FK_SchemeId != id)).ToList();
|
||||||
|
await ValidateDiscountSchemesAsync(storeId, DateTime.Now, paid, added);
|
||||||
|
|
||||||
|
foreach (var id in added)
|
||||||
|
await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = id, FK_ShopPOId = shopPOId, DiscountAmount = 0m });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Discounts already redeemed against a Shop PO, for display and for editing.</summary>
|
||||||
|
public async Task<List<ShopPODiscountResponse>> GetDiscountsForShopPOAsync(Guid shopPOId) =>
|
||||||
|
(await repo.GetRedemptionsByShopPOAsync(shopPOId))
|
||||||
|
.Where(r => r.DiscountAmount.HasValue)
|
||||||
|
.Select(r => new ShopPODiscountResponse(r.FK_SchemeId, r.Scheme?.Name ?? string.Empty, r.DiscountAmount!.Value))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings the Shop PO's scheme redemptions in line with its saved lines: free-goods redemptions follow the
|
||||||
|
/// free lines (created, resized or removed), discount redemptions are recomputed or dropped if the order no
|
||||||
|
/// longer qualifies. Stages changes only — the caller owns the commit.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SyncRedemptionsAsync(Guid shopPOId, List<ShopPOItem> lines, List<Guid>? discountSchemeIds = null)
|
||||||
|
{
|
||||||
|
var existing = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||||
|
var freeBySchemes = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue)
|
||||||
|
.GroupBy(l => l.FK_SchemeId!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
foreach (var r in existing)
|
||||||
|
{
|
||||||
|
if (r.FK_FreeItemId.HasValue)
|
||||||
|
{
|
||||||
|
if (!freeBySchemes.ContainsKey(r.FK_SchemeId)) repo.RemoveRedemption(r);
|
||||||
|
}
|
||||||
|
else if (r.DiscountAmount.HasValue)
|
||||||
|
{
|
||||||
|
// A null list means "leave the accepted discounts alone" (just recompute them).
|
||||||
|
if (discountSchemeIds is not null && !discountSchemeIds.Contains(r.FK_SchemeId))
|
||||||
|
{
|
||||||
|
repo.RemoveRedemption(r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||||
|
if (eval is { Eligible: true, DiscountAmount: not null }) r.DiscountAmount = eval.DiscountAmount;
|
||||||
|
else repo.RemoveRedemption(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var schemeId in discountSchemeIds ?? [])
|
||||||
|
{
|
||||||
|
if (existing.Any(r => r.FK_SchemeId == schemeId && r.DiscountAmount.HasValue)) continue;
|
||||||
|
var scheme = await repo.GetByIdAsync(schemeId);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||||
|
if (eval is { Eligible: true, DiscountAmount: not null })
|
||||||
|
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||||
|
{
|
||||||
|
FK_SchemeId = schemeId,
|
||||||
|
FK_ShopPOId = shopPOId,
|
||||||
|
DiscountAmount = eval.DiscountAmount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (schemeId, group) in freeBySchemes)
|
||||||
|
{
|
||||||
|
var freeItem = await itemRepo.GetByIdAsync(group[0].FK_ItemId)
|
||||||
|
?? throw new InvalidOperationException("Free item not found.");
|
||||||
|
var qty = group.Sum(l => l.Quantity);
|
||||||
|
var redemption = existing.FirstOrDefault(r => r.FK_SchemeId == schemeId && r.FK_FreeItemId.HasValue);
|
||||||
|
|
||||||
|
if (redemption is null)
|
||||||
|
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||||
|
{
|
||||||
|
FK_SchemeId = schemeId,
|
||||||
|
FK_ShopPOId = shopPOId,
|
||||||
|
FK_FreeItemId = freeItem.Id,
|
||||||
|
FreeQty = qty,
|
||||||
|
FreeItemCostValue = freeItem.CostPrice ?? 0m
|
||||||
|
});
|
||||||
|
else
|
||||||
|
{
|
||||||
|
redemption.FK_FreeItemId = freeItem.Id;
|
||||||
|
redemption.FreeQty = qty;
|
||||||
|
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
||||||
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
||||||
@@ -299,14 +620,18 @@ public class SchemeService(
|
|||||||
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||||
|
|
||||||
var today = DateTime.Now;
|
var today = DateTime.Now;
|
||||||
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
|
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
|
||||||
return (false, "Scheme is not currently active (outside its date range).");
|
return (false, "Scheme is not currently active (outside its date range).");
|
||||||
|
|
||||||
return (true, null);
|
return (true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
|
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> allItems)
|
||||||
{
|
{
|
||||||
|
// Free-issue lines (added by a previous redemption) must not count toward a trigger/bundle
|
||||||
|
// quantity, otherwise the free units inflate the multiplier.
|
||||||
|
var items = allItems.Where(i => !i.IsFree && i.UnitPrice > 0).ToList();
|
||||||
|
|
||||||
switch (scheme.RuleType)
|
switch (scheme.RuleType)
|
||||||
{
|
{
|
||||||
case SchemeRuleType.BuyXGetY:
|
case SchemeRuleType.BuyXGetY:
|
||||||
@@ -383,6 +708,9 @@ public class SchemeService(
|
|||||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||||
|
|
||||||
|
if (await repo.HasLockedInvoiceAsync(shopPOId))
|
||||||
|
throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed.");
|
||||||
|
|
||||||
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||||
var eligibility = EvaluateRule(scheme, items);
|
var eligibility = EvaluateRule(scheme, items);
|
||||||
if (!eligibility.Eligible)
|
if (!eligibility.Eligible)
|
||||||
@@ -411,7 +739,10 @@ public class SchemeService(
|
|||||||
FK_ShopPOId = shopPOId,
|
FK_ShopPOId = shopPOId,
|
||||||
FK_ItemId = freeItem.Id,
|
FK_ItemId = freeItem.Id,
|
||||||
Quantity = (int)eligibility.FreeQty.Value,
|
Quantity = (int)eligibility.FreeQty.Value,
|
||||||
UnitPrice = 0m
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = scheme.Id,
|
||||||
|
SchemeName = scheme.Name
|
||||||
});
|
});
|
||||||
|
|
||||||
redemption.FK_FreeItemId = freeItem.Id;
|
redemption.FK_FreeItemId = freeItem.Id;
|
||||||
@@ -422,7 +753,27 @@ public class SchemeService(
|
|||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
if (newItems.Count > 0)
|
||||||
|
{
|
||||||
|
await shopPORepo.AddItemsAsync(newItems);
|
||||||
|
|
||||||
|
// A still-draft invoice was copied from the PO before this line existed, so mirror it there.
|
||||||
|
if (shopPO.InvoiceId is Guid invoiceId &&
|
||||||
|
await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft })
|
||||||
|
{
|
||||||
|
await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoiceId,
|
||||||
|
FK_ShopPOItemId = n.Id,
|
||||||
|
FK_ItemId = n.FK_ItemId,
|
||||||
|
Quantity = n.Quantity,
|
||||||
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = n.FK_SchemeId,
|
||||||
|
SchemeName = n.SchemeName
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
await repo.AddRedemptionAsync(redemption);
|
await repo.AddRedemptionAsync(redemption);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -484,6 +835,8 @@ public class SchemeService(
|
|||||||
|
|
||||||
if (freeItemId is null || freeQty is null or <= 0)
|
if (freeItemId is null || freeQty is null or <= 0)
|
||||||
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
||||||
|
if (freeQty.Value != decimal.Truncate(freeQty.Value))
|
||||||
|
throw new InvalidOperationException("FreeQty must be a whole number (order lines hold whole units).");
|
||||||
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
||||||
throw new InvalidOperationException("Free item not found.");
|
throw new InvalidOperationException("Free item not found.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("confirmed-without-invoice")]
|
||||||
|
public async Task<IActionResult> GetConfirmedWithoutInvoice(
|
||||||
|
[FromQuery] Guid? agentId,
|
||||||
|
[FromQuery] Guid? routeId,
|
||||||
|
[FromQuery] Guid? shopId)
|
||||||
|
{
|
||||||
|
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
namespace Distribution.Features.ShopPO;
|
namespace Distribution.Features.ShopPO;
|
||||||
|
|
||||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
|
||||||
|
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
|
||||||
|
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
|
||||||
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
||||||
|
|
||||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
|
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive,
|
||||||
|
bool IsFree = false, Guid? SchemeId = null, string? SchemeName = null);
|
||||||
|
|
||||||
|
public record ShopPODiscountResponse(Guid SchemeId, string SchemeName, decimal Amount);
|
||||||
|
|
||||||
public record ShopPOResponse(
|
public record ShopPOResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -34,4 +39,5 @@ public record ShopPOResponse(
|
|||||||
DateTime? CancelledAt = null,
|
DateTime? CancelledAt = null,
|
||||||
string? CancelReason = null,
|
string? CancelReason = null,
|
||||||
string? CancelledByName = null,
|
string? CancelledByName = null,
|
||||||
string? CancelledByRole = null);
|
string? CancelledByRole = null,
|
||||||
|
List<ShopPODiscountResponse>? Discounts = null);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Distribution.Features.Invoice;
|
using Distribution.Features.Invoice;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -10,16 +12,47 @@ public class ShopPOService(
|
|||||||
IShopPORepository repo,
|
IShopPORepository repo,
|
||||||
IInvoiceRepository invoiceRepo,
|
IInvoiceRepository invoiceRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
|
SchemeService schemeService,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
|
||||||
|
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
|
||||||
|
{
|
||||||
|
if (line.IsFree)
|
||||||
|
return new ShopPOItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = line.SchemeId
|
||||||
|
};
|
||||||
|
|
||||||
|
var unitPrice = line.UnitPrice
|
||||||
|
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
|
||||||
|
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||||
|
|
||||||
|
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
|
||||||
|
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
|
||||||
|
notifications.NotifyAsync(
|
||||||
|
type,
|
||||||
|
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
|
||||||
|
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
|
||||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||||
p.Items.Where(i => i.IsActive).Select(i =>
|
p.Items.Where(i => i.IsActive).Select(i =>
|
||||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
|
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
|
||||||
|
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
|
||||||
p.IsActive, p.CreatedAt,
|
p.IsActive, p.CreatedAt,
|
||||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
||||||
@@ -27,7 +60,8 @@ public class ShopPOService(
|
|||||||
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
||||||
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
||||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
|
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
|
||||||
|
discounts ?? []);
|
||||||
|
|
||||||
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||||
{
|
{
|
||||||
@@ -52,7 +86,14 @@ public class ShopPOService(
|
|||||||
var po = await repo.GetByIdAsync(id);
|
var po = await repo.GetByIdAsync(id);
|
||||||
if (po is null) return null;
|
if (po is null) return null;
|
||||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
var users = await repo.GetUserInfoAsync(UserIds(po));
|
||||||
return ToResponse(po, users);
|
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||||
|
{
|
||||||
|
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||||
|
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
||||||
|
return items.Select(p => ToResponse(p, users)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
||||||
@@ -65,20 +106,14 @@ public class ShopPOService(
|
|||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, request.StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
items.Add(item);
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
total += item.Quantity * item.UnitPrice;
|
||||||
|
|
||||||
items.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||||
|
|
||||||
var po = new Models.ShopPO
|
var po = new Models.ShopPO
|
||||||
{
|
{
|
||||||
FK_StoreId = request.StoreId,
|
FK_StoreId = request.StoreId,
|
||||||
@@ -93,6 +128,7 @@ public class ShopPOService(
|
|||||||
await repo.AddAsync(po);
|
await repo.AddAsync(po);
|
||||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||||
await repo.AddItemsAsync(items);
|
await repo.AddItemsAsync(items);
|
||||||
|
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
@@ -120,27 +156,22 @@ public class ShopPOService(
|
|||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
item.FK_ShopPOId = id;
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
newItems.Add(item);
|
||||||
|
total += item.Quantity * item.UnitPrice;
|
||||||
newItems.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ShopPOId = id,
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||||
|
|
||||||
po.TotalAmount = total;
|
po.TotalAmount = total;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await repo.ReplaceItemsAsync(id, newItems);
|
await repo.ReplaceItemsAsync(id, newItems);
|
||||||
|
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -168,6 +199,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,17 +226,25 @@ public class ShopPOService(
|
|||||||
CreatedBy = userId,
|
CreatedBy = userId,
|
||||||
TotalAmount = po.TotalAmount
|
TotalAmount = po.TotalAmount
|
||||||
};
|
};
|
||||||
|
// Start the invoice at the discounted total so it matches what print/finalize will recompute.
|
||||||
|
invoice.DiscountAmount = Math.Min(await invoiceRepo.GetSchemeDiscountAsync(po.Id), po.TotalAmount);
|
||||||
|
invoice.TotalAmount = po.TotalAmount - invoice.DiscountAmount;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await invoiceRepo.AddAsync(invoice);
|
await invoiceRepo.AddAsync(invoice);
|
||||||
|
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
|
||||||
|
await invoiceRepo.AddItemsAsync(lines);
|
||||||
|
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
|
||||||
po.InvoiceId = invoice.Id;
|
po.InvoiceId = invoice.Id;
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(id);
|
var saved = await repo.GetByIdAsync(id);
|
||||||
return await ToResponseWithUserAsync(saved!);
|
return await ToResponseWithUserAsync(saved!);
|
||||||
}
|
}
|
||||||
@@ -231,6 +272,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Scheme;
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.StoreCredit;
|
using Distribution.Features.StoreCredit;
|
||||||
@@ -7,7 +8,7 @@ using Distribution.Models;
|
|||||||
|
|
||||||
namespace Distribution.Features.Store;
|
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 GlobalScopeRoleLevel = 50;
|
||||||
private const int CreditApproverRoleLevel = 20;
|
private const int CreditApproverRoleLevel = 20;
|
||||||
@@ -148,6 +149,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
var store = await repo.GetByIdAsync(id);
|
var store = await repo.GetByIdAsync(id);
|
||||||
if (store is null || !store.IsActive) return null;
|
if (store is null || !store.IsActive) return null;
|
||||||
if (!await HasStoreScopeAsync(id)) return null;
|
if (!await HasStoreScopeAsync(id)) return null;
|
||||||
|
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
|
||||||
store.OutstandingBalance = request.NewBalance;
|
store.OutstandingBalance = request.NewBalance;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
@@ -160,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
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);
|
return ToResponse(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -9,6 +10,7 @@ public class SupplierClaimService(
|
|||||||
ISupplierClaimRepository repo,
|
ISupplierClaimRepository repo,
|
||||||
IPoRepository poRepo,
|
IPoRepository poRepo,
|
||||||
IGrnRepository grnRepo,
|
IGrnRepository grnRepo,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||||
@@ -149,9 +151,19 @@ public class SupplierClaimService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
|
||||||
|
|
||||||
return Map(claim);
|
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)
|
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var claim = await repo.GetByIdAsync(id)
|
var claim = await repo.GetByIdAsync(id)
|
||||||
@@ -172,6 +184,8 @@ public class SupplierClaimService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
|
||||||
|
|
||||||
return Map(claim);
|
return Map(claim);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.ChequeBook;
|
using Distribution.Features.ChequeBook;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
using Distribution.Features.Supplier;
|
using Distribution.Features.Supplier;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
@@ -18,8 +17,7 @@ public class SupplierPaymentService(
|
|||||||
IBankAccountRepository bankAccountRepo,
|
IBankAccountRepository bankAccountRepo,
|
||||||
IChequeBookRepository chequeBookRepo,
|
IChequeBookRepository chequeBookRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
||||||
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
||||||
@@ -163,13 +161,14 @@ public class SupplierPaymentService(
|
|||||||
|
|
||||||
await repo.AddAsync(payment);
|
await repo.AddAsync(payment);
|
||||||
|
|
||||||
|
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||||
|
// Debit Accounts Payable (supplier)
|
||||||
|
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
// Mirrors to the General Ledger after commit: Dr Payable / Cr Cash/Bank (or Cheques-issued). Never throws.
|
|
||||||
await gl.PostSupplierPaymentAsync(payment, ct);
|
|
||||||
|
|
||||||
return ToResponse(payment);
|
return ToResponse(payment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,12 +98,6 @@ namespace Distribution.Migrations
|
|||||||
table: "DeliveryTasks",
|
table: "DeliveryTasks",
|
||||||
newName: "FK_ShopPOId");
|
newName: "FK_ShopPOId");
|
||||||
|
|
||||||
// MariaDB drops the ascii collation on CHANGE COLUMN; restore it so the FK to ShopPOs.Id can be created.
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `DeliveryTasks` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `SchemeRedemptions` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NULL;");
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `SaleReturns` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `Payments` MODIFY `FK_InvoiceId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
migrationBuilder.RenameIndex(
|
||||||
name: "IX_DeliveryTasks_FK_OrderId",
|
name: "IX_DeliveryTasks_FK_OrderId",
|
||||||
table: "DeliveryTasks",
|
table: "DeliveryTasks",
|
||||||
|
|||||||
+163
-211
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace Distribution.Migrations
|
namespace Distribution.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(AppDBContext))]
|
[DbContext(typeof(AppDBContext))]
|
||||||
[Migration("20261020082853_GlIntegration")]
|
[Migration("20261020075213_AddNotifications")]
|
||||||
partial class GlIntegration
|
partial class AddNotifications
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -59,9 +59,6 @@ namespace Distribution.Migrations
|
|||||||
.HasMaxLength(10)
|
.HasMaxLength(10)
|
||||||
.HasColumnType("varchar(10)");
|
.HasColumnType("varchar(10)");
|
||||||
|
|
||||||
b.Property<string>("GlAccountCode")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
@@ -631,57 +628,6 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.GlPostingLog", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<int>("Attempts")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<string>("Error")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<string>("Event")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(60)
|
|
||||||
.HasColumnType("varchar(60)");
|
|
||||||
|
|
||||||
b.Property<string>("GlJournalNo")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("tinyint(1)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastAttemptAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<string>("PayloadJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<string>("Reference")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<Guid>("SourceId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Event", "SourceId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("GlPostingLogs");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1335,6 +1281,93 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("LoadingSheetOrders");
|
b.ToTable("LoadingSheetOrders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("EntityType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Link")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("Severity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("varchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("varchar(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EntityType", "EntityId");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_NotificationId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_UserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReadAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_NotificationId", "FK_UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("FK_UserId", "IsRead");
|
||||||
|
|
||||||
|
b.ToTable("NotificationRecipients");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1868,22 +1901,6 @@ namespace Distribution.Migrations
|
|||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Description = "Update system configuration settings",
|
Description = "Update system configuration settings",
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("20000000-0000-0000-0000-000000000051"),
|
|
||||||
Code = "finance-reports.view",
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)",
|
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("20000000-0000-0000-0000-000000000052"),
|
|
||||||
Code = "gl.manage",
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Description = "Set up the General Ledger link and retry failed postings",
|
|
||||||
IsActive = true
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2601,7 +2618,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("52dedbb8-6251-4131-82de-41c2064a663b"),
|
Id = new Guid("e748cecd-7b03-41ba-96f4-e7e80d8b39f5"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2609,7 +2626,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("47450af3-cf95-4725-a208-eeca124022b7"),
|
Id = new Guid("6ec338c9-dbe1-40b0-895c-228bcb439815"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2617,7 +2634,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("44deed45-fc6f-420c-a026-f4483efab782"),
|
Id = new Guid("eb390164-72ff-4b03-8d09-184f25fe2e0b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2625,7 +2642,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"),
|
Id = new Guid("e8b5c2b0-cdba-42f0-b1f0-32a892036c0a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2633,7 +2650,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"),
|
Id = new Guid("48340fe7-3037-4ab1-8800-59763bce0855"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2641,7 +2658,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"),
|
Id = new Guid("d43d5a8b-4a0a-4a7a-9b34-7c0561ed9303"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2649,7 +2666,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("16ad892c-b785-4872-a426-1015f32c90b9"),
|
Id = new Guid("41c7bd2c-0253-4315-8594-811aec35f15c"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2657,7 +2674,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"),
|
Id = new Guid("87052d54-7319-473e-a46f-62d6477ef7d8"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2665,7 +2682,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("498230b5-7d98-4826-afaf-944123658f69"),
|
Id = new Guid("c0b0185e-3530-4cc8-9e6b-b51cd1187dda"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2673,7 +2690,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"),
|
Id = new Guid("6e649c16-703b-4420-ae23-8840d1dc247b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2681,7 +2698,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"),
|
Id = new Guid("efe29c4c-353a-4b25-9279-6566c8ca1b3d"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2689,7 +2706,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"),
|
Id = new Guid("3aa9f6cd-0075-4ab2-88c4-c49e177d443a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2697,7 +2714,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"),
|
Id = new Guid("258dfadf-292e-410c-a173-217983becc2b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2705,7 +2722,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"),
|
Id = new Guid("c253907f-f84e-4ea5-af4a-0db56b89b82a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2713,7 +2730,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"),
|
Id = new Guid("c47972c0-6a5f-47cf-ae30-cbf753a6fe94"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2721,7 +2738,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"),
|
Id = new Guid("ee4b11f3-78d4-4e6f-bb22-95f3f18be7e5"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2729,7 +2746,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("86229744-f68d-4e7e-9b07-2aad58584331"),
|
Id = new Guid("a0a39f4e-95c9-42bc-9803-a510fa4863d7"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2737,7 +2754,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"),
|
Id = new Guid("29e55c7f-92cb-47fd-b05c-ce038284ff39"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2745,7 +2762,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"),
|
Id = new Guid("7b675ce2-3949-4e1a-ad6e-e1a047d84ece"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2753,7 +2770,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"),
|
Id = new Guid("835c1000-3998-444c-b9bf-53b2ef810437"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2761,7 +2778,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("48772797-f181-423d-bfb3-f6226a9abe11"),
|
Id = new Guid("3c9d0a5d-059e-4e0b-b1a8-698ce2a076ae"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2769,7 +2786,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"),
|
Id = new Guid("f12fc85a-5a91-4e23-81de-0b497d4bc246"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2777,7 +2794,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"),
|
Id = new Guid("08005497-616c-4279-aa8a-162762d57a88"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2785,7 +2802,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"),
|
Id = new Guid("666a9e41-1c11-4af3-8f72-c80ba3734a48"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2793,7 +2810,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"),
|
Id = new Guid("87cd58d6-68f2-4de0-a0d8-5951a2281184"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2801,7 +2818,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"),
|
Id = new Guid("a7a0cb93-598b-4e21-9bab-ae5ba4da71d3"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2809,7 +2826,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"),
|
Id = new Guid("86bf0611-4798-4209-a5e4-a0064a5e95cd"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2817,7 +2834,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("8f63c426-38bb-456d-adff-75e477857607"),
|
Id = new Guid("bd386f32-0f7a-4954-9e85-15956b4fb718"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2825,7 +2842,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"),
|
Id = new Guid("9dd219a4-da77-49f5-8ea1-097dbba231ea"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2833,7 +2850,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"),
|
Id = new Guid("5caf3706-bde2-43bf-b9a8-d351500e6cba"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2841,7 +2858,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"),
|
Id = new Guid("63ba1d73-9819-4471-a89b-1eacade54d84"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2849,7 +2866,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("12e98296-456e-40a3-b11c-61d3393b335e"),
|
Id = new Guid("58398d5c-4687-4a7f-a205-d6a3ff365c6f"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2857,7 +2874,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"),
|
Id = new Guid("72761e2e-cd4f-44d1-b446-d5ab974374d4"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2865,7 +2882,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("5e701ad5-f462-4642-86d1-303148323a32"),
|
Id = new Guid("66c1a66f-5d2d-4532-8f47-441a9a9df5c3"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2873,7 +2890,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"),
|
Id = new Guid("6e37280f-b1cc-4aba-a8df-2f42ee1958a0"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2881,7 +2898,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"),
|
Id = new Guid("dd4149c0-8746-4ceb-92c2-27cec640fca2"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2889,7 +2906,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"),
|
Id = new Guid("e51495d1-bb65-4345-8156-73c791589d1f"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2897,7 +2914,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"),
|
Id = new Guid("ccf5af23-acd7-4bdb-a2ea-ec409a970add"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2905,7 +2922,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"),
|
Id = new Guid("11e001a5-7a47-4778-996b-0f2aa1e4ae73"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2913,7 +2930,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"),
|
Id = new Guid("6b1edf33-51c0-4cae-ba2f-9737e0c91e42"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2921,7 +2938,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("a2515783-f099-4332-a07c-be81f2e85160"),
|
Id = new Guid("0a54915c-1660-4657-96fb-e55d01e8a31a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2929,7 +2946,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"),
|
Id = new Guid("646a6638-8520-4fd8-8608-aa7e29bb874d"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2937,7 +2954,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"),
|
Id = new Guid("8fe75d07-1c6c-4019-becc-d5e82f2cb777"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2945,7 +2962,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"),
|
Id = new Guid("05732dc6-a967-481b-b055-76ff68c5728e"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2953,7 +2970,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"),
|
Id = new Guid("864da17e-470f-41f5-a053-07f112c090ea"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2961,7 +2978,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"),
|
Id = new Guid("f8c31d84-f57f-4515-86f2-733442d9ea14"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2969,7 +2986,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"),
|
Id = new Guid("ba7e7d95-51f7-4ebb-b911-9bc18a104a39"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2977,7 +2994,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"),
|
Id = new Guid("b4cfa315-1b8a-4f61-af60-5389391f973c"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2985,7 +3002,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"),
|
Id = new Guid("a03e5eff-1925-4ddf-904b-32be0daed97b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2993,23 +3010,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("93c036e4-d811-4915-b73c-1cb358f17065"),
|
Id = new Guid("be5ff487-33d9-4c63-81f4-fd7f6553c5bf"),
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000051"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("9c27d325-1915-41c6-9b62-d85a12f102e1"),
|
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000052"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("1f48dd7b-2f14-451d-b80d-71579d5625e6"),
|
|
||||||
IsActive = true
|
IsActive = true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3994,54 +3995,6 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.StoreCreditMovement", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
|
||||||
.HasPrecision(18, 2)
|
|
||||||
.HasColumnType("decimal(18,2)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<int>("Direction")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<Guid?>("FK_InvoiceId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<Guid?>("FK_PaymentId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<Guid>("FK_StoreId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("tinyint(1)");
|
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<DateTime>("OccurredAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<Guid?>("RecordedBy")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("FK_InvoiceId");
|
|
||||||
|
|
||||||
b.HasIndex("FK_PaymentId");
|
|
||||||
|
|
||||||
b.HasIndex("FK_StoreId", "OccurredAt");
|
|
||||||
|
|
||||||
b.ToTable("StoreCreditMovements");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -5104,6 +5057,25 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("ShopPO");
|
b.Navigation("ShopPO");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Notification", "Notification")
|
||||||
|
.WithMany("Recipients")
|
||||||
|
.HasForeignKey("FK_NotificationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Notification");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
||||||
@@ -5536,31 +5508,6 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Route");
|
b.Navigation("Route");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.StoreCreditMovement", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Distribution.Models.Invoice", "Invoice")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("FK_InvoiceId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Payment", "Payment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("FK_PaymentId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Store", "Store")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("FK_StoreId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Invoice");
|
|
||||||
|
|
||||||
b.Navigation("Payment");
|
|
||||||
|
|
||||||
b.Navigation("Store");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Category", "Category")
|
b.HasOne("Distribution.Models.Category", "Category")
|
||||||
@@ -5821,6 +5768,11 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Orders");
|
b.Navigation("Orders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Recipients");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,814 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
|
||||||
|
|
||||||
namespace Distribution.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class GlIntegration : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "GlAccountCode",
|
|
||||||
table: "BankAccounts",
|
|
||||||
type: "longtext",
|
|
||||||
nullable: true)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4");
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "GlPostingLogs",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
|
||||||
Event = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
|
||||||
SourceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
|
||||||
Reference = table.Column<string>(type: "longtext", nullable: false)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
|
||||||
Status = table.Column<int>(type: "int", nullable: false),
|
|
||||||
PayloadJson = table.Column<string>(type: "longtext", nullable: false)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
|
||||||
GlJournalNo = table.Column<string>(type: "longtext", nullable: true)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
|
||||||
Error = table.Column<string>(type: "longtext", nullable: true)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
|
||||||
Attempts = table.Column<int>(type: "int", nullable: false),
|
|
||||||
LastAttemptAt = 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_GlPostingLogs", x => x.Id);
|
|
||||||
})
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4");
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "BankAccounts",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
|
||||||
column: "GlAccountCode",
|
|
||||||
value: null);
|
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
|
||||||
table: "Permissions",
|
|
||||||
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
|
|
||||||
values: new object[,]
|
|
||||||
{
|
|
||||||
{ new Guid("20000000-0000-0000-0000-000000000051"), "finance-reports.view", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "View financial reports (trial balance, P&L, balance sheet, cash flow...)", true },
|
|
||||||
{ new Guid("20000000-0000-0000-0000-000000000052"), "gl.manage", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Set up the General Ledger link and retry failed postings", true }
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("52dedbb8-6251-4131-82de-41c2064a663b"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("47450af3-cf95-4725-a208-eeca124022b7"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("44deed45-fc6f-420c-a026-f4483efab782"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("16ad892c-b785-4872-a426-1015f32c90b9"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("498230b5-7d98-4826-afaf-944123658f69"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("86229744-f68d-4e7e-9b07-2aad58584331"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("48772797-f181-423d-bfb3-f6226a9abe11"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("8f63c426-38bb-456d-adff-75e477857607"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("12e98296-456e-40a3-b11c-61d3393b335e"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("5e701ad5-f462-4642-86d1-303148323a32"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("a2515783-f099-4332-a07c-be81f2e85160"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("93c036e4-d811-4915-b73c-1cb358f17065"));
|
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
|
|
||||||
values: new object[,]
|
|
||||||
{
|
|
||||||
{ new Guid("20000000-0000-0000-0000-000000000051"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("9c27d325-1915-41c6-9b62-d85a12f102e1"), true },
|
|
||||||
{ new Guid("20000000-0000-0000-0000-000000000052"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1f48dd7b-2f14-451d-b80d-71579d5625e6"), true }
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_GlPostingLogs_Event_SourceId",
|
|
||||||
table: "GlPostingLogs",
|
|
||||||
columns: new[] { "Event", "SourceId" },
|
|
||||||
unique: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "GlPostingLogs");
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000051"), new Guid("10000000-0000-0000-0000-000000000007") });
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000052"), new Guid("10000000-0000-0000-0000-000000000007") });
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Permissions",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("20000000-0000-0000-0000-000000000051"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Permissions",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("20000000-0000-0000-0000-000000000052"));
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "GlAccountCode",
|
|
||||||
table: "BankAccounts");
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("be712eda-98a5-4a30-820a-1737e84d1e4b"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("47ea9420-d6a0-4376-a2b8-211639d95b27"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("369eed1f-df4c-4131-a3c6-ebbe63d2dc91"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("14b6944f-2d4a-4378-a974-c6fc0f9dfb81"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("14a01e0b-7be5-4c65-ae6a-899f9aa7ad28"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("5d486ccd-3866-4e7d-8ef1-fb177fb637c1"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("d830e59b-f451-4bde-9a77-78daea5f9a61"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("096d3837-a649-4184-8987-2ea0dfa2d4a1"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("5e43bbde-689b-4d07-baf9-0764078b5586"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("f317d30c-1644-44f0-b0aa-6bcf1fa66f44"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("31ffac6f-2aa8-48ee-bc87-213f35933de4"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("fb525bc4-9f16-455a-bfd5-ca6f3e7ad141"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("2388918f-6392-4384-b269-0a1469f5bf4c"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("7159012e-5f25-4881-b5e8-5300e382332e"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("cc4b2e82-ad42-4440-92ba-9b2100bb5f84"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("6b15c133-ea0e-473d-b58a-7f140e9f03bf"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("c2dd52a9-380f-479f-8e5e-6b56bfc4738f"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("bf5f6c7a-2fde-4a01-83f4-aacf91333c4c"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("162a0346-c4c0-4c4e-96e9-aa8154d25b39"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("5cc76807-25d1-45e5-9da3-cfd6d6f3ad7c"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("51a2653e-4b1a-4f6f-80c2-f533b26109ea"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("e2a382e2-d712-4a97-a7ec-cd6232d77a78"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("b60260b5-70b0-4f9e-9ab9-ba4abbbdfb02"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("a73fe794-383c-4157-89cf-227c4e487a6d"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("a9743e96-15b2-4c1d-b410-698cd6db0b7b"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("a94d92ab-66d8-4dd9-8ba3-c242eb450e68"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("9cb1a028-a98e-492a-aafc-70456fe46c96"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("5bc99a62-f5d7-48fe-b25c-518e4774614c"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("eceee678-5cee-4ee9-b16d-8d8d21fbb5ea"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("3a049ed7-75b7-4b1d-b3bb-402ca77c350f"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("aeaab85c-5b02-4bc2-b0e3-318c0f5b07d3"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("b79800e0-baa6-4add-9374-7d1e2ed650e8"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("0da6bf0b-26ff-46b9-bb8c-f9c641b37a89"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("ff84aeeb-93c0-465d-9a4a-8b686ba7bc75"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("921f8073-7a66-49bb-ae23-cf8fcde12c9c"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("bff05501-4988-4c8a-b61d-e32389e795a8"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("2c4f7432-e697-4d65-97df-069f8913a662"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("18d27746-3178-4c57-afd5-b75f0061637f"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("a5d1978b-c497-4314-857b-a7e3b8323a53"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("88300763-b8c3-4bd2-803b-df0e43bc691e"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("7aaad5a9-4497-429f-b3dc-9b59664e2e19"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("2db130c1-4b6f-4719-b3bb-7bd8a0662d75"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("be349d14-38b5-4a13-a152-9ea055de26b1"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("5292e02d-9a7d-4a18-be14-86c2f3351cac"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("97021474-1218-4d21-9115-4b9b2726e38b"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("56f68355-7b1b-49e4-829c-1a4d47fc93de"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("3017c8cb-f9e4-41c8-ac2e-f03d23fe944a"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("619a08e5-f6cb-4515-8b4d-a094d19277d8"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("cb8b4e96-7026-4485-8b52-3145b5289350"));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "RolePermissions",
|
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
|
|
||||||
column: "Id",
|
|
||||||
value: new Guid("f0f57dfd-21a5-4a0b-a77e-e641f00300fa"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,9 +56,6 @@ namespace Distribution.Migrations
|
|||||||
.HasMaxLength(10)
|
.HasMaxLength(10)
|
||||||
.HasColumnType("varchar(10)");
|
.HasColumnType("varchar(10)");
|
||||||
|
|
||||||
b.Property<string>("GlAccountCode")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
@@ -628,57 +625,6 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.GlPostingLog", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<int>("Attempts")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<string>("Error")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<string>("Event")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(60)
|
|
||||||
.HasColumnType("varchar(60)");
|
|
||||||
|
|
||||||
b.Property<string>("GlJournalNo")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("tinyint(1)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastAttemptAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<string>("PayloadJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<string>("Reference")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<Guid>("SourceId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Event", "SourceId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("GlPostingLogs");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -902,6 +848,9 @@ namespace Distribution.Migrations
|
|||||||
b.Property<Guid>("CreatedBy")
|
b.Property<Guid>("CreatedBy")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_ShopPOId")
|
b.Property<Guid>("FK_ShopPOId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -941,6 +890,7 @@ namespace Distribution.Migrations
|
|||||||
Id = new Guid("34500000-0000-0000-0000-000000000001"),
|
Id = new Guid("34500000-0000-0000-0000-000000000001"),
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified),
|
||||||
CreatedBy = new Guid("29000000-0000-0000-0000-000000000002"),
|
CreatedBy = new Guid("29000000-0000-0000-0000-000000000002"),
|
||||||
|
DiscountAmount = 0m,
|
||||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||||
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
FinalizedBy = new Guid("29000000-0000-0000-0000-000000000001"),
|
FinalizedBy = new Guid("29000000-0000-0000-0000-000000000001"),
|
||||||
@@ -956,6 +906,7 @@ namespace Distribution.Migrations
|
|||||||
Id = new Guid("34500000-0000-0000-0000-000000000002"),
|
Id = new Guid("34500000-0000-0000-0000-000000000002"),
|
||||||
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
CreatedBy = new Guid("29000000-0000-0000-0000-000000000003"),
|
CreatedBy = new Guid("29000000-0000-0000-0000-000000000003"),
|
||||||
|
DiscountAmount = 0m,
|
||||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
PaymentStatus = 1,
|
PaymentStatus = 1,
|
||||||
@@ -966,6 +917,132 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.InvoiceItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_InvoiceId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_SchemeId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_ShopPOItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsFree")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRemoved")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SchemeName")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_InvoiceId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ShopPOItemId");
|
||||||
|
|
||||||
|
b.ToTable("InvoiceItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.InvoiceVersion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedBy")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_InvoiceId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Stage")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<int>("VersionNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_InvoiceId", "VersionNumber")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("InvoiceVersions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.InvoiceVersionItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_InvoiceVersionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_ShopPOItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRemoved")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_InvoiceVersionId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ShopPOItemId");
|
||||||
|
|
||||||
|
b.ToTable("InvoiceVersionItems");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1332,6 +1409,93 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("LoadingSheetOrders");
|
b.ToTable("LoadingSheetOrders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("EntityType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Link")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("Severity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("varchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("varchar(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EntityType", "EntityId");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_NotificationId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_UserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReadAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_NotificationId", "FK_UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("FK_UserId", "IsRead");
|
||||||
|
|
||||||
|
b.ToTable("NotificationRecipients");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1865,22 +2029,6 @@ namespace Distribution.Migrations
|
|||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Description = "Update system configuration settings",
|
Description = "Update system configuration settings",
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("20000000-0000-0000-0000-000000000051"),
|
|
||||||
Code = "finance-reports.view",
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)",
|
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("20000000-0000-0000-0000-000000000052"),
|
|
||||||
Code = "gl.manage",
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Description = "Set up the General Ledger link and retry failed postings",
|
|
||||||
IsActive = true
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2598,7 +2746,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("52dedbb8-6251-4131-82de-41c2064a663b"),
|
Id = new Guid("2a4abd94-1720-49a7-89f3-4e835781b40a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2606,7 +2754,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("47450af3-cf95-4725-a208-eeca124022b7"),
|
Id = new Guid("dea99413-dea7-4353-adb6-2684f628ed6d"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2614,7 +2762,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("44deed45-fc6f-420c-a026-f4483efab782"),
|
Id = new Guid("9ca45530-7f89-4aab-81f3-34b70ddf9bde"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2622,7 +2770,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"),
|
Id = new Guid("12a89406-c43a-483f-9b78-97ca64aca95b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2630,7 +2778,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"),
|
Id = new Guid("4859dde8-21b4-412b-9bfd-8adc5b68410a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2638,7 +2786,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"),
|
Id = new Guid("74038a6f-5fd5-4bc5-a78a-a83602bc22db"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2646,7 +2794,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("16ad892c-b785-4872-a426-1015f32c90b9"),
|
Id = new Guid("ae20b416-a508-4ffc-92a1-e98aaeadb8b6"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2654,7 +2802,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"),
|
Id = new Guid("1fe41f0e-a01e-4481-b4f6-451de1e9481d"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2662,7 +2810,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("498230b5-7d98-4826-afaf-944123658f69"),
|
Id = new Guid("252ff69b-fe2b-48dc-97cc-29bc1b605c4d"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2670,7 +2818,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"),
|
Id = new Guid("78617da6-504a-4c8f-b4db-22679230b6de"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2678,7 +2826,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"),
|
Id = new Guid("731cc179-ec73-4b36-8345-3b7f961738cd"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2686,7 +2834,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"),
|
Id = new Guid("462c0177-9604-4dde-8946-be1f003c26d5"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2694,7 +2842,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"),
|
Id = new Guid("9401e464-23ea-4c0f-89b3-166a5a52c2b2"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2702,7 +2850,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"),
|
Id = new Guid("ad7d3861-64b7-482c-90d1-34a1c588a4bc"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2710,7 +2858,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"),
|
Id = new Guid("33bbfe87-566f-4f7f-a140-8557242b38bd"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2718,7 +2866,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"),
|
Id = new Guid("9e20caa7-c5fd-461b-8345-0688a436461f"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2726,7 +2874,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("86229744-f68d-4e7e-9b07-2aad58584331"),
|
Id = new Guid("75dfbd5d-43de-41d8-b8c3-1fc222d4ecec"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2734,7 +2882,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"),
|
Id = new Guid("52dcde7a-122e-4721-9c81-f1a413eff01e"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2742,7 +2890,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"),
|
Id = new Guid("1646eda4-31b4-47ba-b11b-77b5a1e389d4"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2750,7 +2898,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"),
|
Id = new Guid("cf3a7f0c-9877-494f-a07d-d693edec4cde"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2758,7 +2906,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("48772797-f181-423d-bfb3-f6226a9abe11"),
|
Id = new Guid("b36eefd7-54a5-465a-9b3b-43418b6c8f5b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2766,7 +2914,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"),
|
Id = new Guid("615f12f1-07d3-463b-8259-f7e3926284b4"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2774,7 +2922,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"),
|
Id = new Guid("f3b1cf5c-ecb6-4b63-982c-feaa9afc73f5"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2782,7 +2930,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"),
|
Id = new Guid("b370f645-4b32-4275-8ae1-c92714d30731"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2790,7 +2938,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"),
|
Id = new Guid("1575ffb9-123f-4625-8c5f-d68d56a33bd3"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2798,7 +2946,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"),
|
Id = new Guid("8341015a-a078-4f4a-9d32-c8cc0c92654a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2806,7 +2954,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"),
|
Id = new Guid("6078f921-206b-431c-956a-50ed8d99a004"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2814,7 +2962,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("8f63c426-38bb-456d-adff-75e477857607"),
|
Id = new Guid("67a3e0b2-26e7-4d57-8318-c17b6f4788f2"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2822,7 +2970,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"),
|
Id = new Guid("5949601b-3458-4fd3-adc7-3ca4ede91272"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2830,7 +2978,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"),
|
Id = new Guid("4c85ff7a-854b-4c07-8101-fa14a8699dd8"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2838,7 +2986,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"),
|
Id = new Guid("36837347-3d26-453b-a445-97331e82fa43"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2846,7 +2994,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("12e98296-456e-40a3-b11c-61d3393b335e"),
|
Id = new Guid("437e060a-ebf0-4cc0-bbb0-63ad7be9f177"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2854,7 +3002,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"),
|
Id = new Guid("7a589bc4-ce8c-437a-b91b-a4153f36c9fd"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2862,7 +3010,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("5e701ad5-f462-4642-86d1-303148323a32"),
|
Id = new Guid("b13dffb9-2a6c-4d57-8194-d0fb93eedc0a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2870,7 +3018,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"),
|
Id = new Guid("fcbd982e-a963-4818-9361-d0af9b112e79"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2878,7 +3026,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"),
|
Id = new Guid("0e544a73-7236-41a7-acbb-3fe7b807383c"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2886,7 +3034,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"),
|
Id = new Guid("b85957c9-3f01-4043-8659-4cfee8b83c2c"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2894,7 +3042,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"),
|
Id = new Guid("42c8240d-dbfa-45b9-a87f-4130683722d1"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2902,7 +3050,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"),
|
Id = new Guid("b7693644-4a51-4fe9-93e3-e70b0093b772"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2910,7 +3058,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"),
|
Id = new Guid("34b1213b-bc79-4d79-b2e6-d607abdd9467"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2918,7 +3066,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("a2515783-f099-4332-a07c-be81f2e85160"),
|
Id = new Guid("feb1c1cb-3b64-4ff7-8f67-ff1146ced327"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2926,7 +3074,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"),
|
Id = new Guid("1e2ef870-17a5-494b-9d62-a2725fd420f4"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2934,7 +3082,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"),
|
Id = new Guid("efb242ec-dfee-4338-9b36-ecca208d4035"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2942,7 +3090,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"),
|
Id = new Guid("a9fb51c9-19b5-4297-b798-00ae0a6dced5"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2950,7 +3098,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"),
|
Id = new Guid("1ded2baa-f75d-4305-91e4-1778b0c43f95"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2958,7 +3106,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"),
|
Id = new Guid("ffe7a2b9-fe25-46a6-bd71-fa170cf39511"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2966,7 +3114,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"),
|
Id = new Guid("efca52dc-4ae3-4135-a293-77da540943d7"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2974,7 +3122,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"),
|
Id = new Guid("f1901597-9fb0-45a8-841f-eab0a7af9287"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2982,7 +3130,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"),
|
Id = new Guid("763e5759-b12c-434a-b117-ea09d773f4f4"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2990,23 +3138,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("93c036e4-d811-4915-b73c-1cb358f17065"),
|
Id = new Guid("ec9db3b2-59b0-48f3-a6e0-0c81d6fbc854"),
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000051"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("9c27d325-1915-41c6-9b62-d85a12f102e1"),
|
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000052"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("1f48dd7b-2f14-451d-b80d-71579d5625e6"),
|
|
||||||
IsActive = true
|
IsActive = true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3594,18 +3726,27 @@ namespace Distribution.Migrations
|
|||||||
b.Property<Guid>("FK_ItemId")
|
b.Property<Guid>("FK_ItemId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_SchemeId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_ShopPOId")
|
b.Property<Guid>("FK_ShopPOId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsFree")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<int>("Quantity")
|
b.Property<int>("Quantity")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("QuantityFulfilled")
|
b.Property<int>("QuantityFulfilled")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SchemeName")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<decimal>("UnitPrice")
|
b.Property<decimal>("UnitPrice")
|
||||||
.HasColumnType("decimal(65,30)");
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
@@ -3625,6 +3766,7 @@ namespace Distribution.Migrations
|
|||||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
|
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
|
||||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
IsFree = false,
|
||||||
Quantity = 30,
|
Quantity = 30,
|
||||||
QuantityFulfilled = 30,
|
QuantityFulfilled = 30,
|
||||||
UnitPrice = 60m
|
UnitPrice = 60m
|
||||||
@@ -3636,6 +3778,7 @@ namespace Distribution.Migrations
|
|||||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
|
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
|
||||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
IsFree = false,
|
||||||
Quantity = 24,
|
Quantity = 24,
|
||||||
QuantityFulfilled = 24,
|
QuantityFulfilled = 24,
|
||||||
UnitPrice = 50m
|
UnitPrice = 50m
|
||||||
@@ -3647,6 +3790,7 @@ namespace Distribution.Migrations
|
|||||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000002"),
|
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000002"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
IsFree = false,
|
||||||
Quantity = 10,
|
Quantity = 10,
|
||||||
QuantityFulfilled = 0,
|
QuantityFulfilled = 0,
|
||||||
UnitPrice = 120m
|
UnitPrice = 120m
|
||||||
@@ -3658,6 +3802,7 @@ namespace Distribution.Migrations
|
|||||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
IsFree = false,
|
||||||
Quantity = 20,
|
Quantity = 20,
|
||||||
QuantityFulfilled = 20,
|
QuantityFulfilled = 20,
|
||||||
UnitPrice = 120m
|
UnitPrice = 120m
|
||||||
@@ -4984,6 +5129,69 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("ShopPO");
|
b.Navigation("ShopPO");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.InvoiceItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Invoice", "Invoice")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_InvoiceId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.ShopPOItem", "ShopPOItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ShopPOItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Invoice");
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("ShopPOItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.InvoiceVersion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Invoice", "Invoice")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_InvoiceId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Invoice");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.InvoiceVersionItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.InvoiceVersion", "InvoiceVersion")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_InvoiceVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.ShopPOItem", "ShopPOItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ShopPOItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("InvoiceVersion");
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("ShopPOItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Uom", "BaseUom")
|
b.HasOne("Distribution.Models.Uom", "BaseUom")
|
||||||
@@ -5101,6 +5309,25 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("ShopPO");
|
b.Navigation("ShopPO");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Notification", "Notification")
|
||||||
|
.WithMany("Recipients")
|
||||||
|
.HasForeignKey("FK_NotificationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Notification");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
||||||
@@ -5803,9 +6030,16 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
|
||||||
b.Navigation("Payments");
|
b.Navigation("Payments");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.InvoiceVersion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Conversions");
|
b.Navigation("Conversions");
|
||||||
@@ -5818,6 +6052,11 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Orders");
|
b.Navigation("Orders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Recipients");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
|
|||||||
@@ -13,8 +13,5 @@ namespace Distribution.Models
|
|||||||
public string CurrencyCode { get; set; } = "LKR";
|
public string CurrencyCode { get; set; } = "LKR";
|
||||||
|
|
||||||
public bool IsDefault { get; set; }
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
/// <summary>Code of the mirrored General Ledger account (child of GL Cash in Hand / Bank Accounts). Null until synced.</summary>
|
|
||||||
public string? GlAccountCode { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace Distribution.Models
|
|||||||
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
||||||
|
|
||||||
public enum InvoiceStatus { Draft, Printed, Finalized }
|
public enum InvoiceStatus { Draft, Printed, Finalized }
|
||||||
|
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
|
||||||
|
|
||||||
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
namespace Distribution.Models
|
|
||||||
{
|
|
||||||
public enum GlPostingStatus { Pending, Posted, Failed }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One row per journal entry Distribution sends to the General Ledger service. (Event, SourceId) is unique,
|
|
||||||
/// so a business event can never be posted twice; Failed rows keep their payload and can be retried.
|
|
||||||
/// </summary>
|
|
||||||
public class GlPostingLog : Template
|
|
||||||
{
|
|
||||||
public string Event { get; set; } = string.Empty;
|
|
||||||
public Guid SourceId { get; set; }
|
|
||||||
public string Reference { get; set; } = string.Empty;
|
|
||||||
public GlPostingStatus Status { get; set; } = GlPostingStatus.Pending;
|
|
||||||
|
|
||||||
/// <summary>The journal entry body (without periodId, which is resolved from EntryDate at send time).</summary>
|
|
||||||
public string PayloadJson { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? GlJournalNo { get; set; }
|
|
||||||
public string? Error { get; set; }
|
|
||||||
public int Attempts { get; set; }
|
|
||||||
public DateTime? LastAttemptAt { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,13 @@ namespace Distribution.Models
|
|||||||
public DateTime? FinalizedAt { get; set; }
|
public DateTime? FinalizedAt { get; set; }
|
||||||
public Guid? FinalizedBy { 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 decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
|
||||||
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,11 @@ namespace Distribution.Models
|
|||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
public int QuantityFulfilled { get; set; } = 0;
|
public int QuantityFulfilled { get; set; } = 0;
|
||||||
public decimal UnitPrice { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-10
@@ -11,8 +11,8 @@ using Distribution.Features.ChequeBook;
|
|||||||
using Distribution.Features.ReceivedCheque;
|
using Distribution.Features.ReceivedCheque;
|
||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Invoice;
|
using Distribution.Features.Invoice;
|
||||||
@@ -79,6 +79,8 @@ builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
|||||||
builder.Services.AddScoped<ProductReportService>();
|
builder.Services.AddScoped<ProductReportService>();
|
||||||
builder.Services.AddScoped<IDashboardReportRepository, DashboardReportRepository>();
|
builder.Services.AddScoped<IDashboardReportRepository, DashboardReportRepository>();
|
||||||
builder.Services.AddScoped<DashboardReportService>();
|
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<IStockReportRepository, StockReportRepository>();
|
||||||
builder.Services.AddScoped<StockReportService>();
|
builder.Services.AddScoped<StockReportService>();
|
||||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||||
@@ -142,8 +144,15 @@ builder.Services.AddScoped<ChequeBookService>();
|
|||||||
builder.Services.AddScoped<ReceivedChequeService>();
|
builder.Services.AddScoped<ReceivedChequeService>();
|
||||||
builder.Services.AddScoped<SupplierPaymentService>();
|
builder.Services.AddScoped<SupplierPaymentService>();
|
||||||
builder.Services.AddScoped<SysConfigService>();
|
builder.Services.AddScoped<SysConfigService>();
|
||||||
builder.Services.AddScoped<GlSetupService>();
|
|
||||||
builder.Services.AddScoped<GlPostingService>();
|
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.AddMemoryCache();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
@@ -153,13 +162,6 @@ builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(client =>
|
|||||||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||||
client.BaseAddress = new Uri(baseUrl);
|
client.BaseAddress = new Uri(baseUrl);
|
||||||
});
|
});
|
||||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(client =>
|
|
||||||
{
|
|
||||||
var baseUrl = builder.Configuration["GeneralLedger:BaseUrl"] ?? throw new InvalidOperationException("GeneralLedger:BaseUrl is not configured.");
|
|
||||||
var apiKey = builder.Configuration["GeneralLedger:ApiKey"] ?? throw new InvalidOperationException("GeneralLedger:ApiKey is not configured.");
|
|
||||||
client.BaseAddress = new Uri(baseUrl);
|
|
||||||
client.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
|
|
||||||
});
|
|
||||||
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
|
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
|
||||||
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
||||||
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
||||||
@@ -203,5 +205,6 @@ app.UseMiddleware<AuthMiddleware>();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapHub<NotificationHub>(NotificationHub.Path);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
{
|
{
|
||||||
"Urls": "http://localhost:8802",
|
"Urls": "http://localhost:8802",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
// "DefaultConnection": "Server=localhost;Port=3306;Database=distribution;User=root;Password="
|
|
||||||
// live server
|
|
||||||
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
|
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
|
||||||
},
|
},
|
||||||
"GeneralLedger": {
|
|
||||||
"BaseUrl": "http://localhost:6704/api/",
|
|
||||||
"ApiKey": "ff303a4b1919092501a2ab2b8725709177bbb8f0025c58d39de4ad678e2b4eda"
|
|
||||||
},
|
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ order must contain every component in at least its listed `Qty` before the schem
|
|||||||
SchemeRedemption
|
SchemeRedemption
|
||||||
├── Id (Guid) — PK
|
├── Id (Guid) — PK
|
||||||
├── FK_SchemeId (Guid) — required
|
├── 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
|
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
|
||||||
│ entity exists yet, so this is an unconstrained Guid for now
|
│ entity exists yet, so this is an unconstrained Guid for now
|
||||||
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
||||||
@@ -87,8 +87,8 @@ SchemeRedemption
|
|||||||
│ later changes
|
│ later changes
|
||||||
├── RedeemedAt (DateTime)
|
├── RedeemedAt (DateTime)
|
||||||
```
|
```
|
||||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
|
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`ShopPO` restrict-delete
|
||||||
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
(a scheme or Shop PO with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||||
|
|
||||||
### Item (extended)
|
### 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 |
|
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
|
||||||
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
|
| — | `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 |
|
| — | `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 |
|
| `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 |
|
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
|
||||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
|
| `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`.
|
[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
|
(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.)
|
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
|
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
|
||||||
order's date falls within `[DateRangeFrom, DateRangeTo]`.
|
Shop PO's date falls within `[DateRangeFrom, DateRangeTo]`. `DateRangeTo` is inclusive of the
|
||||||
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
|
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)`).
|
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
|
Only **paid** lines (`UnitPrice > 0`) count toward `boughtQty` — free lines added by an earlier
|
||||||
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
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`,
|
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
|
`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.
|
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
|
## Open Questions
|
||||||
|
|
||||||
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
|
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
|
||||||
|
|||||||
@@ -1,67 +1,330 @@
|
|||||||
# 21 — Notifications & Alerts
|
# 21 — Notifications & Alerts
|
||||||
|
|
||||||
**Status**: Not Started
|
**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
|
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
|
Real-time **in-app** notifications (bell + toast) triggered by events other modules already produce
|
||||||
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
|
([mainPlan.md §15](./mainPlan.md#15-notifications--alerts)). Email / SMS / push are out of scope for v1.
|
||||||
produce.
|
|
||||||
|
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
|
Every notification is **in-app only** (bell + toast + `/notifications`), pushed live over SignalR.
|
||||||
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
|
| # | When this happens | Where in the app | Notification the user sees | Who receives it | Who does **not** | Level | Click opens |
|
||||||
[12_store_management.md](./12_store_management.md), route deviation logic in
|
|---|---|---|---|---|---|---|---|
|
||||||
[09_fraud_controls.md](./09_fraud_controls.md)).
|
| 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
|
```
|
||||||
|
Service action ──► uow.CommitAsync() (business transaction, unchanged)
|
||||||
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
|
│
|
||||||
`ReadAt` (nullable).
|
▼ 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) |
|
| First batch of triggers | The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed |
|
||||||
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
|
| Channels | In-app only (SignalR). Email / SMS / push deferred |
|
||||||
| `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)) |
|
| Per-type config table + admin screen | **Not in v1.** Every type is always enabled; add `NotificationModuleConfig` when someone needs to mute a type |
|
||||||
| `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. |
|
| Scheduled alerts (Phase 1b) | Second pass. Only the retention cleanup job ships in v1 |
|
||||||
| — | `GET /api/notifications` — recipient's own notification inbox |
|
| CORS / instances | Existing `Cors:AllowedOrigins` + `AllowCredentials()` already suit SignalR. Single API instance, no Redis backplane |
|
||||||
| — | `PUT /api/notifications/{id}/read` |
|
| 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
|
**Notification** — one row per event
|
||||||
endpoints are inbox read/list.
|
|
||||||
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
|
| Field | Type | Notes |
|
||||||
transaction that triggered them — fire-and-forget, logged separately.
|
|---|---|---|
|
||||||
|
| `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
|
## Open Questions
|
||||||
|
|
||||||
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
|
Resolved — see **Decisions**. Remaining for later:
|
||||||
significantly changes the infrastructure needed (third-party provider integration, see
|
|
||||||
[22_integration_api.md](./22_integration_api.md)).
|
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
|
├── 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`
|
### New Enum — `InvoiceStatus`
|
||||||
|
|
||||||
```csharp
|
```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 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. |
|
| (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. |
|
| 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. |
|
| 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.
|
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.
|
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
|
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
|
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
|
||||||
separate axis from `Invoice.Status` — not merged in this pass.
|
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.
|
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.
|
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
|
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.
|
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||||
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
|
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
||||||
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
|
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
|
||||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
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
|
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
|
||||||
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
|
`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`.
|
- Lifecycle: `Draft → Printed → Finalized`.
|
||||||
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
|
- The invoice has its own lines (`InvoiceItem`), copied from the PO's active lines when it is
|
||||||
quantity) and recomputes `TotalAmount` from the adjusted lines.
|
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`:
|
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
|
||||||
`Pending → PartiallyPaid → Paid`.
|
`Pending → PartiallyPaid → Paid`.
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||||
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||||
|
import { RegionSalesPie } from './region-sales-pie'
|
||||||
|
import { SalesTrendInteractive } from './sales-trend-interactive'
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
from: string // yyyy-MM-dd
|
from: string // yyyy-MM-dd
|
||||||
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
|||||||
function LoadingGrid() {
|
function LoadingGrid() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3" aria-busy="true">
|
<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">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
if (loading || productsLoading || !sales) return <LoadingGrid />
|
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||||
|
|
||||||
const { current: c, previous: p } = sales.kpis
|
const { current: c, previous: p } = sales.kpis
|
||||||
|
const { current: cash, previous: cashPrev } = sales.cash
|
||||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||||
<Kpi
|
<Kpi
|
||||||
title={title}
|
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 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)
|
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<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('Net Sales', 'netSales')}
|
||||||
{kpi('Gross Sales', 'grossSales')}
|
{kpi('Gross Sales', 'grossSales')}
|
||||||
{kpi('Total Orders', 'orders', false)}
|
{kpi('Total Orders', 'orders', false)}
|
||||||
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{kpi('Sales Returns', 'returnValue')}
|
{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>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<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.
|
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
|
||||||
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
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>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<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">
|
<SalesTrendInteractive className="md:col-span-2" />
|
||||||
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
|
||||||
</ChartCard>
|
|
||||||
<ChartCard title="Sales by Category" note="Share of gross sales">
|
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||||
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Sales by Region">
|
<RegionSalesPie rows={sales.byRegion} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||||
<MultiAreaChart
|
<MultiAreaChart
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
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 { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
||||||
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||||
import type { ChartDatum, MonthlySalesPoint } 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: '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: '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: '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: '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: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||||
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
|
|||||||
|
|
||||||
const DEFAULT_ACTIVE = [
|
const DEFAULT_ACTIVE = [
|
||||||
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
||||||
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
|
||||||
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -153,7 +152,6 @@ interface DashboardData {
|
|||||||
confirmedSheetsCount: number
|
confirmedSheetsCount: number
|
||||||
pendingTasksCount: number
|
pendingTasksCount: number
|
||||||
partialTasksCount: number
|
partialTasksCount: number
|
||||||
lowStock: LowStockItemResponse[]
|
|
||||||
movements: StockMovementResponse[]
|
movements: StockMovementResponse[]
|
||||||
warehouseBalances: WarehouseBalanceRow[]
|
warehouseBalances: WarehouseBalanceRow[]
|
||||||
outstandingAmount: number
|
outstandingAmount: number
|
||||||
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
|
|||||||
openRides: [],
|
openRides: [],
|
||||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||||
pendingTasksCount: 0, partialTasksCount: 0,
|
pendingTasksCount: 0, partialTasksCount: 0,
|
||||||
lowStock: [], movements: [], warehouseBalances: [],
|
movements: [], warehouseBalances: [],
|
||||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||||
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
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')) {
|
if (activeIds.has('stockMovements')) {
|
||||||
fetches.push(
|
fetches.push(
|
||||||
stock.movements({ pageSize: 5 })
|
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 className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||||
</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':
|
case 'stockMovements':
|
||||||
return data.movements.length === 0 ? (
|
return data.movements.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ReceiptDollarIcon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import {
|
||||||
|
invoiceCreationMode as invoiceCreationModeApi,
|
||||||
|
DEFAULT_INVOICE_CREATION_MODE,
|
||||||
|
getApiError,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||||
|
|
||||||
|
// What each toggle stands for. Both end the same way: edit the draft, print, finalize.
|
||||||
|
const POSITIONS: { mode: InvoiceCreationMode; label: string; meaning: string; steps: string[] }[] = [
|
||||||
|
{ mode: 'ModeA', label: 'Auto', meaning: 'Confirming a PO also creates its draft invoice.', steps: ['Confirm PO', 'Draft created'] },
|
||||||
|
{ mode: 'ModeB', label: 'Manual', meaning: 'Confirm first, then create the invoice yourself.', steps: ['Confirm PO', 'Create invoice'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function InvoiceCreationModeCard() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoiceCreationModeApi.get()
|
||||||
|
.then(setMode)
|
||||||
|
.catch(err => toast.add({ title: 'Failed to load invoice creation mode', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function save(next: InvoiceCreationMode) {
|
||||||
|
if (next === mode) return
|
||||||
|
const previous = mode
|
||||||
|
setMode(next)
|
||||||
|
setPending(true)
|
||||||
|
try {
|
||||||
|
await invoiceCreationModeApi.set(next)
|
||||||
|
} catch (err) {
|
||||||
|
setMode(previous)
|
||||||
|
toast.add({ title: 'Failed to update invoice creation mode', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full gap-3 rounded-3xl border-border/60 py-4">
|
||||||
|
<CardHeader className="border-b border-border/60 pb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
|
<HugeiconsIcon icon={ReceiptDollarIcon} size={16} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Invoice Creation</CardTitle>
|
||||||
|
<CardDescription className="text-xs">When invoices are created for a Shop PO</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{POSITIONS.map((p) => {
|
||||||
|
const active = p.mode === mode
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.mode}
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
|
||||||
|
active ? 'border-primary/40 bg-muted' : 'border-border/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<label htmlFor={`invoice-creation-${p.mode}`} className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium">{p.label}</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted-foreground">{p.meaning}</span>
|
||||||
|
</label>
|
||||||
|
{/* One mode is always on: turning one on turns the other off; the active one cannot be switched off. */}
|
||||||
|
<Switch
|
||||||
|
id={`invoice-creation-${p.mode}`}
|
||||||
|
checked={active}
|
||||||
|
disabled={pending || active}
|
||||||
|
onCheckedChange={(v) => v && save(p.mode)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{[...(POSITIONS.find((p) => p.mode === mode)?.steps ?? []), 'Edit draft', 'Print', 'Finalize'].join(' → ')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
-350
@@ -1,350 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { usePermission } from '@/hooks/use-permission'
|
|
||||||
import { getApiError } from '@/lib/api/index'
|
|
||||||
import { bankAccounts } from '@/lib/api/bank-accounts'
|
|
||||||
import { generalLedger, type GlPostingRow, type GlStatus, type YearEndPreview } from '@/lib/api/finance-reports'
|
|
||||||
import type { BankAccountResponse } from '@/types/payments'
|
|
||||||
|
|
||||||
const ACCOUNT_LABELS: Record<string, string> = {
|
|
||||||
'Gl.Account.CashEquivalents': 'Cash and cash equivalents',
|
|
||||||
'Gl.Account.CashInHand': 'Cash in hand',
|
|
||||||
'Gl.Account.BankAccounts': 'Bank accounts',
|
|
||||||
'Gl.Account.ChequesInHand': 'Cheques in hand',
|
|
||||||
'Gl.Account.Receivable': 'Trade receivables',
|
|
||||||
'Gl.Account.Inventory': 'Inventory',
|
|
||||||
'Gl.Account.InputVat': 'Input VAT',
|
|
||||||
'Gl.Account.InputNbt': 'Input NBT',
|
|
||||||
'Gl.Account.Payable': 'Trade payables',
|
|
||||||
'Gl.Account.ChequesIssued': 'Cheques issued (uncleared)',
|
|
||||||
'Gl.Account.Equity': "Owner's equity (header)",
|
|
||||||
'Gl.Account.ShareCapital': "Share capital / owner's capital",
|
|
||||||
'Gl.Account.RetainedEarnings': 'Retained earnings',
|
|
||||||
'Gl.Account.Drawings': 'Drawings and dividends',
|
|
||||||
'Gl.Account.GeneralReserve': 'General reserve',
|
|
||||||
'Gl.Account.RevaluationReserve': 'Revaluation reserve',
|
|
||||||
'Gl.Account.Sales': 'Sales revenue',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opening balance form rows. Amounts are each account's normal balance and default to 0 (zero rows are skipped).
|
|
||||||
const OPENING_ROWS: { key: string; label: string; group: 'Assets' | 'Liabilities' | 'Equity' }[] = [
|
|
||||||
{ key: 'ChequesInHand', label: 'Cheques in hand', group: 'Assets' },
|
|
||||||
{ key: 'Receivable', label: 'Trade receivables', group: 'Assets' },
|
|
||||||
{ key: 'Inventory', label: 'Inventory', group: 'Assets' },
|
|
||||||
{ key: 'InputVat', label: 'Input VAT', group: 'Assets' },
|
|
||||||
{ key: 'InputNbt', label: 'Input NBT', group: 'Assets' },
|
|
||||||
{ key: 'Payable', label: 'Trade payables', group: 'Liabilities' },
|
|
||||||
{ key: 'ChequesIssued', label: 'Cheques issued (uncleared)', group: 'Liabilities' },
|
|
||||||
{ key: 'ShareCapital', label: "Share capital / owner's capital", group: 'Equity' },
|
|
||||||
{ key: 'RetainedEarnings', label: 'Retained earnings', group: 'Equity' },
|
|
||||||
{ key: 'GeneralReserve', label: 'General reserve', group: 'Equity' },
|
|
||||||
{ key: 'RevaluationReserve', label: 'Revaluation reserve', group: 'Equity' },
|
|
||||||
{ key: 'Drawings', label: 'Drawings and dividends (reduces equity)', group: 'Equity' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const toNum = (v: string) => (v.trim() === '' ? 0 : Number(v))
|
|
||||||
const fmt = (v: number) => v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
||||||
|
|
||||||
async function fetchState() {
|
|
||||||
const status = await generalLedger.status()
|
|
||||||
const waiting = status.failed > 0
|
|
||||||
? [...(await generalLedger.postings('Failed')), ...(await generalLedger.postings('Pending'))]
|
|
||||||
: []
|
|
||||||
return { status, waiting }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeneralLedgerClient() {
|
|
||||||
const allowed = usePermission('gl.manage')
|
|
||||||
const [status, setStatus] = useState<GlStatus | null>(null)
|
|
||||||
const [failed, setFailed] = useState<GlPostingRow[]>([])
|
|
||||||
const [busy, setBusy] = useState<'setup' | 'retry' | 'opening' | 'preview' | 'close' | null>(null)
|
|
||||||
const [closeYear, setCloseYear] = useState(new Date().getFullYear() - 1)
|
|
||||||
const [preview, setPreview] = useState<YearEndPreview | null>(null)
|
|
||||||
const [cashBank, setCashBank] = useState<BankAccountResponse[]>([])
|
|
||||||
const [amounts, setAmounts] = useState<Record<string, string>>({})
|
|
||||||
const [openingDate, setOpeningDate] = useState(`${new Date().getFullYear()}-01-01`)
|
|
||||||
|
|
||||||
const apply = useCallback((d: { status: GlStatus; waiting: GlPostingRow[] }) => {
|
|
||||||
setStatus(d.status)
|
|
||||||
setFailed(d.waiting)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const reload = useCallback(
|
|
||||||
() => fetchState().then(apply).catch(err => toast.add({ title: 'Could not load General Ledger status', description: getApiError(err), type: 'error' })),
|
|
||||||
[apply],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!allowed) return
|
|
||||||
fetchState()
|
|
||||||
.then(apply)
|
|
||||||
.catch(err => toast.add({ title: 'Could not load General Ledger status', description: getApiError(err), type: 'error' }))
|
|
||||||
}, [allowed, apply])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (allowed) bankAccounts.list().then(setCashBank).catch(() => setCashBank([]))
|
|
||||||
}, [allowed])
|
|
||||||
|
|
||||||
const rowsTotal = (group: 'Assets' | 'Liabilities' | 'Equity') =>
|
|
||||||
OPENING_ROWS.filter(r => r.group === group && r.key !== 'Drawings').reduce((t, r) => t + toNum(amounts[r.key] ?? ''), 0)
|
|
||||||
const bankTotal = cashBank.reduce((t, b) => t + toNum(amounts[`bank:${b.accountId}`] ?? ''), 0)
|
|
||||||
// Drawings is debit-side (reduces equity), so it sits with the assets side of the balance check.
|
|
||||||
const debitsTotal = rowsTotal('Assets') + bankTotal + toNum(amounts.Drawings ?? '')
|
|
||||||
const creditsTotal = rowsTotal('Liabilities') + rowsTotal('Equity')
|
|
||||||
const difference = debitsTotal - creditsTotal
|
|
||||||
|
|
||||||
async function loadPreview() {
|
|
||||||
setBusy('preview')
|
|
||||||
try {
|
|
||||||
setPreview(await generalLedger.yearEndPreview(closeYear))
|
|
||||||
} catch (err) {
|
|
||||||
setPreview(null)
|
|
||||||
toast.add({ title: 'Could not load the year-end preview', description: getApiError(err), type: 'error' })
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runClose() {
|
|
||||||
setBusy('close')
|
|
||||||
try {
|
|
||||||
const result = await generalLedger.closeYear(closeYear)
|
|
||||||
toast.add({
|
|
||||||
title: `${closeYear} closed`,
|
|
||||||
description: `Journal ${result.glJournalNo ?? ''} posted, ${result.periodsLocked} periods locked`,
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
setPreview(null)
|
|
||||||
await reload()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Could not close the year', description: getApiError(err), type: 'error' })
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postOpening() {
|
|
||||||
setBusy('opening')
|
|
||||||
try {
|
|
||||||
const lines = [
|
|
||||||
...cashBank.map(b => ({ account: 'BankAccount', bankAccountId: b.accountId, amount: toNum(amounts[`bank:${b.accountId}`] ?? '') })),
|
|
||||||
...OPENING_ROWS.map(r => ({ account: r.key, amount: toNum(amounts[r.key] ?? '') })),
|
|
||||||
]
|
|
||||||
const result = await generalLedger.postOpeningBalances(openingDate, 'Opening balances', lines)
|
|
||||||
toast.add({ title: 'Opening balances posted', description: result.glJournalNo ? `Journal ${result.glJournalNo}` : undefined, type: 'success' })
|
|
||||||
setAmounts({})
|
|
||||||
await reload()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Could not post opening balances', description: getApiError(err), type: 'error' })
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSetup() {
|
|
||||||
setBusy('setup')
|
|
||||||
try {
|
|
||||||
const result = await generalLedger.setup()
|
|
||||||
toast.add({
|
|
||||||
title: 'General Ledger set up',
|
|
||||||
description: result.actions.length ? `${result.actions.length} change(s) applied.` : 'Nothing to change — already up to date.',
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
await reload()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Setup failed', description: getApiError(err), type: 'error' })
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retry() {
|
|
||||||
setBusy('retry')
|
|
||||||
try {
|
|
||||||
const result = await generalLedger.retry()
|
|
||||||
toast.add({ title: 'Retry finished', description: `${result.posted} posting(s) sent to the General Ledger.`, type: 'success' })
|
|
||||||
await reload()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Retry failed', description: getApiError(err), type: 'error' })
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
return <p className="text-sm text-muted-foreground">You do not have permission to manage the General Ledger link.</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">General Ledger Integration</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
Sales, goods receipts, payments and cheque movements are mirrored into the General Ledger so financial reports can be produced.
|
|
||||||
Sales, goods receipts and payments made before setup are not posted.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{([
|
|
||||||
['General Ledger service', status.glReachable ? 'Reachable' : 'Unreachable'],
|
|
||||||
['Setup', status.setupDone ? 'Done' : 'Not run'],
|
|
||||||
['Posted entries', String(status.posted)],
|
|
||||||
['Waiting / failed', String(status.failed)],
|
|
||||||
] as const).map(([label, value]) => (
|
|
||||||
<Card key={label} className="rounded-3xl border-border/60">
|
|
||||||
<CardContent className="space-y-1">
|
|
||||||
<CardDescription>{label}</CardDescription>
|
|
||||||
<CardTitle className="text-xl">{value}</CardTitle>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button size="sm" onClick={runSetup} disabled={busy !== null}>
|
|
||||||
{busy === 'setup' ? 'Running…' : status?.setupDone ? 'Re-run setup' : 'Run setup'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={retry} disabled={busy !== null || !status || status.failed === 0}>
|
|
||||||
{busy === 'retry' ? 'Retrying…' : 'Retry failed postings'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status?.setupDone && (
|
|
||||||
<Card className="rounded-3xl border-border/60">
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Close financial year</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Moves the year's sales and expenses into Retained Earnings with one closing entry and locks the year's periods.
|
|
||||||
Anything dated in that year afterwards will be rejected by the General Ledger. Reports for the year still show its own figures.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Input className="w-32" type="number" value={closeYear} onChange={e => { setCloseYear(Number(e.target.value)); setPreview(null) }} />
|
|
||||||
<Button size="sm" variant="outline" onClick={loadPreview} disabled={busy !== null}>
|
|
||||||
{busy === 'preview' ? 'Checking…' : 'Preview'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{preview && (
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<p>
|
|
||||||
Income {fmt(preview.totalIncome)} · Expenses {fmt(preview.totalExpenses)} · Net {preview.netProfit >= 0 ? 'profit' : 'loss'} {fmt(Math.abs(preview.netProfit))}
|
|
||||||
</p>
|
|
||||||
{preview.reason && <p className="text-amber-600">{preview.reason}</p>}
|
|
||||||
<Button size="sm" onClick={runClose} disabled={busy !== null || !preview.closable}>
|
|
||||||
{busy === 'close' ? 'Closing…' : `Close ${preview.year}`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status && status.closedYears.length > 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">Closed years: {status.closedYears.join(', ')}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status?.setupDone && !status.openingBalancePosted && (
|
|
||||||
<div className="rounded-3xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-foreground">
|
|
||||||
<p className="font-semibold">Opening balances have not been entered.</p>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
The ledger and every financial report start from zero. If the business already has cash, bank balances, stock, receivables,
|
|
||||||
payables or capital, enter them below (all amounts default to 0). Equity accounts such as Share Capital only appear in the
|
|
||||||
Balance Sheet once they hold a balance.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status?.setupDone && (
|
|
||||||
<Card className="rounded-3xl border-border/60">
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Opening balances</h2>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">As of</span>
|
|
||||||
<Input className="w-44" type="date" value={openingDate} onChange={e => setOpeningDate(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{(['Assets', 'Liabilities', 'Equity'] as const).map(group => (
|
|
||||||
<div key={group} className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">{group}</p>
|
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
|
||||||
{group === 'Assets' && cashBank.map(b => (
|
|
||||||
<label key={b.accountId} className="flex items-center justify-between gap-2 text-sm">
|
|
||||||
<span>{b.accountType === 'Bank' && b.bankName ? `${b.bankName} - ${b.accountName}` : b.accountName}</span>
|
|
||||||
<Input className="w-40 text-right" type="number" min="0" placeholder="0" value={amounts[`bank:${b.accountId}`] ?? ''}
|
|
||||||
onChange={e => setAmounts(a => ({ ...a, [`bank:${b.accountId}`]: e.target.value }))} />
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{OPENING_ROWS.filter(r => r.group === group).map(r => (
|
|
||||||
<label key={r.key} className="flex items-center justify-between gap-2 text-sm">
|
|
||||||
<span>{r.label}</span>
|
|
||||||
<Input className="w-40 text-right" type="number" min="0" placeholder="0" value={amounts[r.key] ?? ''}
|
|
||||||
onChange={e => setAmounts(a => ({ ...a, [r.key]: e.target.value }))} />
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<p className={`text-sm ${difference === 0 ? 'text-muted-foreground' : 'text-amber-600'}`}>
|
|
||||||
Debit side (assets, drawings) {fmt(debitsTotal)} · Credit side (liabilities, equity) {fmt(creditsTotal)}
|
|
||||||
{difference !== 0 && ` · difference ${fmt(Math.abs(difference))} (adjust share capital or retained earnings)`}
|
|
||||||
</p>
|
|
||||||
<Button size="sm" onClick={postOpening} disabled={busy !== null || (debitsTotal === 0 && creditsTotal === 0) || difference !== 0}>
|
|
||||||
{busy === 'opening' ? 'Posting…' : 'Post opening balances'}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status?.setupDone && (
|
|
||||||
<Card className="rounded-3xl border-border/60">
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Account mapping</h2>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow><TableHead>Purpose</TableHead><TableHead>GL account code</TableHead></TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Object.entries(status.mapping).map(([key, code]) => (
|
|
||||||
<TableRow key={key}>
|
|
||||||
<TableCell>{ACCOUNT_LABELS[key] ?? key}</TableCell>
|
|
||||||
<TableCell>{code ?? '—'}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{failed.length > 0 && (
|
|
||||||
<Card className="rounded-3xl border-border/60">
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Postings waiting to be sent</h2>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow><TableHead>Event</TableHead><TableHead>Reference</TableHead><TableHead>Attempts</TableHead><TableHead>Error</TableHead></TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{failed.map(p => (
|
|
||||||
<TableRow key={p.id}>
|
|
||||||
<TableCell>{p.event}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">{p.sourceId}</TableCell>
|
|
||||||
<TableCell>{p.attempts}</TableCell>
|
|
||||||
<TableCell className="text-red-600">{p.error ?? '—'}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { GeneralLedgerClient } from './_components/general-ledger-client'
|
|
||||||
|
|
||||||
export default function GeneralLedgerPage() {
|
|
||||||
return <GeneralLedgerClient />
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
||||||
import { GrnTaxCard } from './_components/grn-tax-card'
|
import { GrnTaxCard } from './_components/grn-tax-card'
|
||||||
|
import { InvoiceCreationModeCard } from './_components/invoice-creation-mode-card'
|
||||||
|
|
||||||
export default function ConfigurationPage() {
|
export default function ConfigurationPage() {
|
||||||
return (
|
return (
|
||||||
@@ -11,7 +12,6 @@ export default function ConfigurationPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */}
|
|
||||||
<div className="grid grid-cols-12 items-stretch gap-6">
|
<div className="grid grid-cols-12 items-stretch gap-6">
|
||||||
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
<ProductCapabilitiesCard />
|
<ProductCapabilitiesCard />
|
||||||
@@ -19,6 +19,9 @@ export default function ConfigurationPage() {
|
|||||||
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
<GrnTaxCard />
|
<GrnTaxCard />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
|
<InvoiceCreationModeCard />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
|
|||||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||||
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
||||||
|
import { NotificationBell } from '@/components/layout/notification-bell'
|
||||||
|
import { NotificationProvider } from '@/contexts/notification-context'
|
||||||
import { TopLoader } from '@/components/shared/top-loader'
|
import { TopLoader } from '@/components/shared/top-loader'
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -10,21 +12,24 @@ export default function DashboardLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<NotificationProvider>
|
||||||
<TopLoader />
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<TopLoader />
|
||||||
<SidebarInset>
|
<AppSidebar />
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
<SidebarInset>
|
||||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||||
<div className="ml-auto">
|
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||||
<ThemeToggle />
|
<div className="ml-auto flex items-center gap-1">
|
||||||
</div>
|
<NotificationBell />
|
||||||
</header>
|
<ThemeToggle />
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
</div>
|
||||||
{children}
|
</header>
|
||||||
</main>
|
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||||
</SidebarInset>
|
{children}
|
||||||
<BottomNav />
|
</main>
|
||||||
</SidebarProvider>
|
</SidebarInset>
|
||||||
|
<BottomNav />
|
||||||
|
</SidebarProvider>
|
||||||
|
</NotificationProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state'
|
||||||
|
import { NotificationRow } from '@/components/layout/notification-bell'
|
||||||
|
import { useNotifications } from '@/contexts/notification-context'
|
||||||
|
import { getApiError } from '@/lib/api'
|
||||||
|
import { notifications } from '@/lib/api/notifications'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import type { NotificationResponse } from '@/types/notifications'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
type Filter = 'all' | 'unread'
|
||||||
|
|
||||||
|
export function NotificationsClient() {
|
||||||
|
const { recent, unreadCount, open, markAllRead, refresh } = useNotifications()
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<Filter>('all')
|
||||||
|
const [items, setItems] = useState<NotificationResponse[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// The caller decides whether to show the loading state (it is true on first render).
|
||||||
|
const load = useCallback(async (targetPage: number, reset: boolean) => {
|
||||||
|
try {
|
||||||
|
const result = await notifications.list({
|
||||||
|
unreadOnly: filter === 'unread',
|
||||||
|
page: targetPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
setItems(prev => (reset ? result.items : [...prev, ...result.items]))
|
||||||
|
setPage(targetPage)
|
||||||
|
setTotal(result.total)
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: 'Could not load notifications', description: getApiError(error), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
// Reload when the filter changes or a new notification is pushed in.
|
||||||
|
const latestId = recent[0]?.id
|
||||||
|
useEffect(() => { void load(1, true) }, [load, latestId])
|
||||||
|
|
||||||
|
async function handleOpen(n: NotificationResponse) {
|
||||||
|
setItems(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
|
||||||
|
await open(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkAllRead() {
|
||||||
|
await markAllRead()
|
||||||
|
await load(1, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(n: NotificationResponse) {
|
||||||
|
try {
|
||||||
|
await notifications.remove(n.id)
|
||||||
|
setItems(prev => prev.filter(x => x.id !== n.id))
|
||||||
|
setTotal(t => Math.max(0, t - 1))
|
||||||
|
void refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: 'Could not delete notification', description: getApiError(error), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = items.length < total
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Notifications</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{unreadCount > 0 ? `${unreadCount} unread` : 'You\'re all caught up'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex rounded-2xl border border-border p-0.5">
|
||||||
|
{(['all', 'unread'] as const).map(f => (
|
||||||
|
<Button
|
||||||
|
key={f}
|
||||||
|
variant={filter === f ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setLoading(true); setFilter(f) }}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'All' : 'Unread'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" disabled={unreadCount === 0} onClick={() => { void handleMarkAllRead() }}>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border">
|
||||||
|
{items.length === 0 && !loading ? (
|
||||||
|
<EmptyState
|
||||||
|
title={filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
|
||||||
|
description="Alerts about deliveries, stock, claims and orders will show up here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{items.map(n => (
|
||||||
|
<div key={n.id} className="group relative">
|
||||||
|
<NotificationRow notification={n} onOpen={item => { void handleOpen(item) }} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Delete notification"
|
||||||
|
className="absolute top-2 right-2 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
onClick={() => { void handleDelete(n) }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<Button variant="outline" disabled={loading} onClick={() => { setLoading(true); void load(page + 1, false) }} className="self-center">
|
||||||
|
{loading ? 'Loading…' : 'Load more'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotificationsClient } from './_components/notifications-client'
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return <NotificationsClient />
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { paymentReports, getApiError } from '@/lib/api/index'
|
||||||
|
import type { PaymentAnalyticsResponse } from '@/lib/api/reports'
|
||||||
|
|
||||||
|
import { daysFromToday, fmtCompact, fmtMoney, fmtNum, fmtShortDay } from '@/app/(dashboard)/network/warehouses/_components/dashboard/format'
|
||||||
|
import { EmptyNote, Metric, Section } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared'
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: '7 days', days: -6 },
|
||||||
|
{ label: '30 days', days: -29 },
|
||||||
|
{ label: '90 days', days: -89 },
|
||||||
|
{ label: '1 year', days: -364 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const flowConfig = {
|
||||||
|
cash: { label: 'Cash', color: 'var(--chart-1)' },
|
||||||
|
cheque: { label: 'Cheque', color: 'var(--chart-2)' },
|
||||||
|
credit: { label: 'Credit', color: 'var(--chart-3)' },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const METHOD_COLOR: Record<string, string> = {
|
||||||
|
Cash: 'var(--chart-1)',
|
||||||
|
Cheque: 'var(--chart-2)',
|
||||||
|
Credit: 'var(--chart-3)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodConfig = {
|
||||||
|
Cash: { label: 'Cash', color: METHOD_COLOR.Cash },
|
||||||
|
Cheque: { label: 'Cheque', color: METHOD_COLOR.Cheque },
|
||||||
|
Credit: { label: 'Credit', color: METHOD_COLOR.Credit },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const balanceConfig = { balance: { label: 'Outstanding', color: 'var(--chart-4)' } } satisfies ChartConfig
|
||||||
|
|
||||||
|
const periodLabel = (p: string) => (p.length === 10 ? fmtShortDay(p) : p)
|
||||||
|
|
||||||
|
export default function PaymentAnalyticsPage() {
|
||||||
|
const [from, setFrom] = useState(() => daysFromToday(-29))
|
||||||
|
const [to, setTo] = useState(() => daysFromToday(0))
|
||||||
|
const [data, setData] = useState<PaymentAnalyticsResponse | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!from || !to) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
paymentReports.analytics({ from, to })
|
||||||
|
.then(res => { if (!cancelled) setData(res) })
|
||||||
|
.catch(err => { if (!cancelled) setError(getApiError(err)) })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [from, to])
|
||||||
|
|
||||||
|
const s = data?.summary
|
||||||
|
const hasFlow = !!data && data.byMethod.some(m => m.amount > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Payment Analytics</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Money flow by method, collections and outstanding receivables.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{PRESETS.map(p => (
|
||||||
|
<Button
|
||||||
|
key={p.label}
|
||||||
|
variant={from === daysFromToday(p.days) && to === daysFromToday(0) ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setFrom(daysFromToday(p.days)); setTo(daysFromToday(0)) }}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Input type="date" value={from} max={to} onChange={e => setFrom(e.target.value)} className="w-40" aria-label="From date" />
|
||||||
|
<Input type="date" value={to} min={from} onChange={e => setTo(e.target.value)} className="w-40" aria-label="To date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && !data && loading && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && s && (
|
||||||
|
<div className={loading ? 'flex flex-col gap-6 opacity-60 transition-opacity' : 'flex flex-col gap-6'}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
|
<Metric label="Invoiced" value={fmtMoney(s.invoiced)} sub="Finalized in period" />
|
||||||
|
<Metric label="Collected" value={fmtMoney(s.collected)} sub={`${fmtNum(s.paymentCount)} payments · cash + cheque`} />
|
||||||
|
<Metric label="Credit extended" value={fmtMoney(s.creditExtended)} />
|
||||||
|
<Metric label="Outstanding" value={fmtMoney(s.outstanding)} sub="As of end date" tone={s.outstanding > 0 ? 'warning' : undefined} />
|
||||||
|
<Metric label="Overdue" value={fmtMoney(s.overdue)} sub="Unpaid over 30 days" tone={s.overdue > 0 ? 'danger' : undefined} />
|
||||||
|
<Metric label="Collection rate" value={s.collectionRatePct == null ? '—' : `${s.collectionRatePct}%`} sub="Collected ÷ invoiced" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Section title="Money flow" description="Amount received per period, by method">
|
||||||
|
{!hasFlow ? (
|
||||||
|
<EmptyNote>No payments in this period.</EmptyNote>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={flowConfig} className="w-full" style={{ height: 260 }}>
|
||||||
|
<BarChart data={data.trend} margin={{ left: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="period" tickFormatter={periodLabel} tickLine={false} axisLine={false} minTickGap={16} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent labelFormatter={(l) => periodLabel(String(l))} formatter={(v) => fmtMoney(Number(v))} />} />
|
||||||
|
<Bar dataKey="cash" stackId="a" fill="var(--color-cash)" />
|
||||||
|
<Bar dataKey="cheque" stackId="a" fill="var(--color-cheque)" />
|
||||||
|
<Bar dataKey="credit" stackId="a" fill="var(--color-credit)" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="By payment method" description="Share of total amount">
|
||||||
|
{!hasFlow ? (
|
||||||
|
<EmptyNote>No payments in this period.</EmptyNote>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ChartContainer config={methodConfig} className="mx-auto w-full" style={{ height: 160 }}>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="method" formatter={(v) => fmtMoney(Number(v))} />} />
|
||||||
|
<Pie data={data.byMethod} dataKey="amount" nameKey="method" innerRadius={44} outerRadius={70} strokeWidth={2}>
|
||||||
|
{data.byMethod.map(m => <Cell key={m.method} fill={METHOD_COLOR[m.method]} />)}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{data.byMethod.map(m => (
|
||||||
|
<li key={m.method} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="size-2.5 rounded-full" style={{ background: METHOD_COLOR[m.method] }} />
|
||||||
|
{m.method} <span className="text-xs text-muted-foreground">· {fmtNum(m.count)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">{fmtMoney(m.amount)} <span className="text-xs text-muted-foreground">({m.sharePct}%)</span></span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Section title="Outstanding aging" description="Unpaid balance by days since invoice finalized">
|
||||||
|
{s.outstanding === 0 ? (
|
||||||
|
<EmptyNote>Nothing outstanding.</EmptyNote>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={balanceConfig} className="w-full" style={{ height: 220 }}>
|
||||||
|
<BarChart data={data.aging} margin={{ left: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="bucket" tickLine={false} axisLine={false} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel formatter={(v) => fmtMoney(Number(v))} />} />
|
||||||
|
<Bar dataKey="balance" fill="var(--color-balance)" radius={6} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Top outstanding stores" description="Largest unpaid balances as of end date">
|
||||||
|
{data.topOutstanding.length === 0 ? (
|
||||||
|
<EmptyNote>No outstanding balances.</EmptyNote>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Store</TableHead>
|
||||||
|
<TableHead className="text-right">Invoices</TableHead>
|
||||||
|
<TableHead className="text-right">Outstanding</TableHead>
|
||||||
|
<TableHead className="text-right">Overdue</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.topOutstanding.map(r => (
|
||||||
|
<TableRow key={r.storeId}>
|
||||||
|
<TableCell className="font-medium">{r.storeName}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmtNum(r.invoices)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmtMoney(r.balance)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{r.overdue > 0 ? <span className="text-destructive">{fmtMoney(r.overdue)}</span> : '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Section title="Cash by account" description="Cash payments received into each account">
|
||||||
|
{data.byAccount.length === 0 ? (
|
||||||
|
<EmptyNote>No cash payments in this period.</EmptyNote>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Account</TableHead>
|
||||||
|
<TableHead className="text-right">Payments</TableHead>
|
||||||
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.byAccount.map(a => (
|
||||||
|
<TableRow key={a.accountId ?? 'none'}>
|
||||||
|
<TableCell className="font-medium">{a.accountName}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmtNum(a.count)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmtMoney(a.amount)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Cheque pipeline" description="Cheque payments in period by cheque status" href="/payments/received-cheques" hrefLabel="Received cheques">
|
||||||
|
{data.cheques.length === 0 ? (
|
||||||
|
<EmptyNote>No cheque payments in this period.</EmptyNote>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Cheques</TableHead>
|
||||||
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.cheques.map(c => (
|
||||||
|
<TableRow key={c.status}>
|
||||||
|
<TableCell><Badge variant={c.status === 'Returned' ? 'destructive' : 'secondary'}>{c.status}</Badge></TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmtNum(c.count)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmtMoney(c.amount)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { SchemeResponse } from '@/types/api'
|
||||||
|
import { SchemeForm } from '../../_components/scheme-form'
|
||||||
|
|
||||||
|
export default function EditSchemePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
schemesApi
|
||||||
|
.get(id)
|
||||||
|
.then(setScheme)
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
|
||||||
|
router.push('/pricing/schemes')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id, router])
|
||||||
|
|
||||||
|
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
|
||||||
|
return <SchemeForm scheme={scheme} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { SchemeResponse } from '@/types/api'
|
||||||
|
import { SchemeForm } from '../_components/scheme-form'
|
||||||
|
|
||||||
|
export default function SchemeDetailsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
schemesApi
|
||||||
|
.get(id)
|
||||||
|
.then(setScheme)
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
|
||||||
|
router.push('/pricing/schemes')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id, router])
|
||||||
|
|
||||||
|
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
|
||||||
|
return <SchemeForm scheme={scheme} readOnly />
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useFieldArray, useWatch } from 'react-hook-form'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { Add01Icon, ArrowLeft01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import {
|
||||||
|
schemes as schemesApi,
|
||||||
|
regions as regionsApi,
|
||||||
|
items as itemsApi,
|
||||||
|
getApiError,
|
||||||
|
getPaginatedItems,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { SchemeResponse, RegionResponse, ItemSummaryResponse, SchemeRuleType } from '@/types/api'
|
||||||
|
import { RULE_META, parseRuleConfig } from './scheme-labels'
|
||||||
|
|
||||||
|
const RULE_ORDER: SchemeRuleType[] = ['BuyXGetY', 'FreeBundle', 'FlatPercent', 'SlabDiscount']
|
||||||
|
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
|
||||||
|
regionId: z.string().optional(),
|
||||||
|
eligibleSegment: z.string().optional(),
|
||||||
|
dateRangeFrom: z.string().min(1, 'Required'),
|
||||||
|
dateRangeTo: z.string().min(1, 'Required'),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
// Buy & get free
|
||||||
|
triggerItemId: z.string(),
|
||||||
|
triggerQty: z.number(),
|
||||||
|
// Buy & get free + combo gift: what the store receives
|
||||||
|
freeItemId: z.string(),
|
||||||
|
freeQty: z.number(),
|
||||||
|
// Combo gift: the products that must be bought together
|
||||||
|
bundle: z.array(z.object({ itemId: z.string(), qty: z.number() })),
|
||||||
|
// Flat discount
|
||||||
|
discountPercent: z.number(),
|
||||||
|
// Spend more, save more
|
||||||
|
tiers: z.array(z.object({ minOrderAmount: z.number(), discountPercent: z.number() })),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
const fail = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path, message })
|
||||||
|
const wholeQty = (n: number) => Number.isInteger(n) && n >= 1
|
||||||
|
const pct = (n: number) => n > 0 && n <= 100
|
||||||
|
|
||||||
|
if (v.dateRangeFrom && v.dateRangeTo && v.dateRangeFrom > v.dateRangeTo)
|
||||||
|
fail(['dateRangeTo'], 'End date must not be before the start date')
|
||||||
|
|
||||||
|
if (v.ruleType === 'BuyXGetY') {
|
||||||
|
if (!v.triggerItemId) fail(['triggerItemId'], 'Choose the product the store must buy')
|
||||||
|
if (!wholeQty(v.triggerQty)) fail(['triggerQty'], 'Enter a whole number, 1 or more')
|
||||||
|
}
|
||||||
|
if (v.ruleType === 'BuyXGetY' || v.ruleType === 'FreeBundle') {
|
||||||
|
if (!v.freeItemId) fail(['freeItemId'], 'Choose the free product')
|
||||||
|
if (!wholeQty(v.freeQty)) fail(['freeQty'], 'Enter a whole number, 1 or more')
|
||||||
|
}
|
||||||
|
if (v.ruleType === 'FreeBundle') {
|
||||||
|
if (v.bundle.length === 0) fail(['bundle'], 'Add at least one product')
|
||||||
|
v.bundle.forEach((b, i) => {
|
||||||
|
if (!b.itemId) fail(['bundle', i, 'itemId'], 'Choose a product')
|
||||||
|
if (!wholeQty(b.qty)) fail(['bundle', i, 'qty'], 'Whole number, 1 or more')
|
||||||
|
})
|
||||||
|
const ids = v.bundle.map((b) => b.itemId).filter(Boolean)
|
||||||
|
if (new Set(ids).size !== ids.length) fail(['bundle'], 'Each product can only be listed once')
|
||||||
|
}
|
||||||
|
if (v.ruleType === 'FlatPercent' && !pct(v.discountPercent))
|
||||||
|
fail(['discountPercent'], 'Enter a percentage between 1 and 100')
|
||||||
|
if (v.ruleType === 'SlabDiscount') {
|
||||||
|
if (v.tiers.length === 0) fail(['tiers'], 'Add at least one level')
|
||||||
|
v.tiers.forEach((t, i) => {
|
||||||
|
if (!(t.minOrderAmount > 0)) fail(['tiers', i, 'minOrderAmount'], 'Enter an amount above 0')
|
||||||
|
if (!pct(t.discountPercent)) fail(['tiers', i, 'discountPercent'], '1 to 100')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
const EMPTY: FormValues = {
|
||||||
|
name: '',
|
||||||
|
ruleType: 'BuyXGetY',
|
||||||
|
regionId: '',
|
||||||
|
eligibleSegment: '',
|
||||||
|
dateRangeFrom: '',
|
||||||
|
dateRangeTo: '',
|
||||||
|
isActive: true,
|
||||||
|
triggerItemId: '',
|
||||||
|
triggerQty: 10,
|
||||||
|
freeItemId: '',
|
||||||
|
freeQty: 1,
|
||||||
|
bundle: [{ itemId: '', qty: 1 }],
|
||||||
|
discountPercent: 10,
|
||||||
|
tiers: [{ minOrderAmount: 5000, discountPercent: 5 }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormValues(scheme: SchemeResponse): FormValues {
|
||||||
|
const cfg = parseRuleConfig(scheme)
|
||||||
|
return {
|
||||||
|
...EMPTY,
|
||||||
|
name: scheme.name,
|
||||||
|
ruleType: scheme.ruleType,
|
||||||
|
regionId: scheme.regionId ?? '',
|
||||||
|
eligibleSegment: scheme.eligibleSegment ?? '',
|
||||||
|
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
|
||||||
|
dateRangeTo: scheme.dateRangeTo.split('T')[0],
|
||||||
|
isActive: scheme.isActive,
|
||||||
|
triggerItemId: scheme.triggerItemId ?? '',
|
||||||
|
triggerQty: scheme.triggerQty ?? EMPTY.triggerQty,
|
||||||
|
freeItemId: scheme.freeItemId ?? '',
|
||||||
|
freeQty: scheme.freeQty ?? EMPTY.freeQty,
|
||||||
|
bundle: scheme.bundleComponents.length
|
||||||
|
? scheme.bundleComponents.map((c) => ({ itemId: c.itemId, qty: c.qty }))
|
||||||
|
: EMPTY.bundle,
|
||||||
|
discountPercent: cfg.discountPercent || EMPTY.discountPercent,
|
||||||
|
tiers: cfg.tiers.length ? cfg.tiers : EMPTY.tiers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemeFormProps {
|
||||||
|
/** null = create a new scheme, otherwise edit this one. */
|
||||||
|
scheme: SchemeResponse | null
|
||||||
|
/** Show the scheme exactly as the edit page does, but locked, with an Edit button. */
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemeForm({ scheme, readOnly = false }: SchemeFormProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/pricing/schemes')
|
||||||
|
const isEdit = !!scheme
|
||||||
|
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||||
|
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: EMPTY })
|
||||||
|
const bundle = useFieldArray({ control: form.control, name: 'bundle' })
|
||||||
|
const tiers = useFieldArray({ control: form.control, name: 'tiers' })
|
||||||
|
const v = useWatch({ control: form.control }) as FormValues
|
||||||
|
const ruleType = v.ruleType ?? 'BuyXGetY'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(scheme ? toFormValues(scheme) : EMPTY)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [scheme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([regionsApi.list({ pageSize: 100 }), itemsApi.list({ pageSize: 200 })])
|
||||||
|
.then(([regions, items]) => {
|
||||||
|
setRegionsList(getPaginatedItems(regions))
|
||||||
|
setItemsList(getPaginatedItems(items))
|
||||||
|
})
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load regions or products', type: 'error' }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const itemName = (id?: string) => itemsList.find((i) => i.id === id)?.name
|
||||||
|
|
||||||
|
// Live sentence so the user can check the offer reads the way they intended.
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const n = (x?: number) => (x && x > 0 ? x : '?')
|
||||||
|
if (ruleType === 'BuyXGetY') {
|
||||||
|
const buy = itemName(v.triggerItemId) ?? 'a product'
|
||||||
|
const free = itemName(v.freeItemId) ?? 'a product'
|
||||||
|
return `Buy ${n(v.triggerQty)} × ${buy}, get ${n(v.freeQty)} × ${free} free. Repeats: buying ${n(v.triggerQty && v.triggerQty * 2)} earns ${n(v.freeQty && v.freeQty * 2)} free.`
|
||||||
|
}
|
||||||
|
if (ruleType === 'FreeBundle') {
|
||||||
|
const parts = (v.bundle ?? []).map((b) => `${n(b.qty)} × ${itemName(b.itemId) ?? 'a product'}`).join(' + ')
|
||||||
|
return `Buy ${parts || 'products'} together, get ${n(v.freeQty)} × ${itemName(v.freeItemId) ?? 'a product'} free (once per order).`
|
||||||
|
}
|
||||||
|
if (ruleType === 'FlatPercent') return `${n(v.discountPercent)}% off the whole order.`
|
||||||
|
const levels = [...(v.tiers ?? [])]
|
||||||
|
.sort((a, b) => a.minOrderAmount - b.minOrderAmount)
|
||||||
|
.map((t) => `${n(t.discountPercent)}% off from Rs. ${(t.minOrderAmount || 0).toLocaleString()}`)
|
||||||
|
return levels.length ? `${levels.join('; ')}. The highest level the order reaches applies.` : 'Add at least one level.'
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [v, ruleType, itemsList])
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
const isGoods = values.ruleType === 'BuyXGetY' || values.ruleType === 'FreeBundle'
|
||||||
|
const ruleConfig =
|
||||||
|
values.ruleType === 'FlatPercent'
|
||||||
|
? JSON.stringify({ discountPercent: values.discountPercent })
|
||||||
|
: values.ruleType === 'SlabDiscount'
|
||||||
|
? JSON.stringify({ tiers: values.tiers })
|
||||||
|
: null
|
||||||
|
// The name is optional: when left blank, describe the offer itself (e.g. "Buy 10 Soap, get 1 Soap free").
|
||||||
|
const n = (id: string) => itemName(id) ?? 'item'
|
||||||
|
const autoName =
|
||||||
|
values.ruleType === 'BuyXGetY'
|
||||||
|
? `Buy ${values.triggerQty} ${n(values.triggerItemId)}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||||
|
: values.ruleType === 'FreeBundle'
|
||||||
|
? `Buy ${values.bundle.map((b) => `${b.qty} ${n(b.itemId)}`).join(' + ')}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||||
|
: values.ruleType === 'FlatPercent'
|
||||||
|
? `${values.discountPercent}% off the order`
|
||||||
|
: `Up to ${Math.max(...values.tiers.map((t) => t.discountPercent))}% off by order value`
|
||||||
|
const common = {
|
||||||
|
name: values.name.trim() || autoName,
|
||||||
|
ruleConfig,
|
||||||
|
regionId: values.regionId || null,
|
||||||
|
eligibleSegment: values.eligibleSegment?.trim() || null,
|
||||||
|
dateRangeFrom: values.dateRangeFrom,
|
||||||
|
dateRangeTo: values.dateRangeTo,
|
||||||
|
isActive: values.isActive,
|
||||||
|
}
|
||||||
|
const goods = isGoods
|
||||||
|
? {
|
||||||
|
triggerItemId: values.ruleType === 'BuyXGetY' ? values.triggerItemId : null,
|
||||||
|
triggerQty: values.ruleType === 'BuyXGetY' ? values.triggerQty : undefined,
|
||||||
|
freeItemId: values.freeItemId,
|
||||||
|
freeQty: values.freeQty,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!scheme) {
|
||||||
|
await schemesApi.create({
|
||||||
|
...common,
|
||||||
|
ruleType: values.ruleType,
|
||||||
|
...goods,
|
||||||
|
bundleComponents:
|
||||||
|
values.ruleType === 'FreeBundle' ? values.bundle.map((b) => ({ itemId: b.itemId, qty: b.qty })) : undefined,
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Scheme created', type: 'success' })
|
||||||
|
} else {
|
||||||
|
// The rule type is fixed once created; update the basics, then the free-goods rule and bundle rows.
|
||||||
|
await schemesApi.update(scheme.id, { ...common, agentLimitExempt: scheme.agentLimitExempt })
|
||||||
|
if (isGoods) {
|
||||||
|
await schemesApi.setFreeGoodsRule(scheme.id, {
|
||||||
|
triggerItemId: goods.triggerItemId,
|
||||||
|
triggerQty: goods.triggerQty,
|
||||||
|
freeItemId: values.freeItemId,
|
||||||
|
freeQty: values.freeQty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (values.ruleType === 'FreeBundle') {
|
||||||
|
const keep = new Set(values.bundle.map((b) => b.itemId))
|
||||||
|
for (const old of scheme.bundleComponents) {
|
||||||
|
if (!keep.has(old.itemId)) await schemesApi.removeBundleComponent(scheme.id, old.itemId)
|
||||||
|
}
|
||||||
|
for (const b of values.bundle) await schemesApi.upsertBundleComponent(scheme.id, b.itemId, b.qty)
|
||||||
|
}
|
||||||
|
toast.add({ title: 'Scheme updated', type: 'success' })
|
||||||
|
}
|
||||||
|
goBack()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemSelect = (value: string, onChange: (v: string) => void, placeholder: string, invalid?: boolean) => (
|
||||||
|
<Select value={value} onValueChange={(x) => onChange(x ?? '')}>
|
||||||
|
<SelectTrigger aria-invalid={invalid}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{itemsList.map((i) => (
|
||||||
|
<SelectItem key={i.id} value={i.id}>
|
||||||
|
{i.name} — {i.sku}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberInput = (value: number, onChange: (n: number) => void, invalid?: boolean, props?: { min?: number; max?: number; step?: string }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={Number.isFinite(value) ? value : ''}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={goBack}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">
|
||||||
|
{readOnly ? 'Scheme Details' : isEdit ? 'Edit Scheme' : 'Add Scheme'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{readOnly
|
||||||
|
? 'How this offer is set up.'
|
||||||
|
: isEdit ? 'Update the offer below.' : 'Pick the kind of offer, then fill in the details.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{readOnly && scheme && (
|
||||||
|
<Button type="button" className="ml-auto" onClick={() => router.push(`/pricing/schemes/${scheme.id}/edit`)}>
|
||||||
|
Edit Scheme
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<fieldset disabled={readOnly} className="m-0 min-w-0 rounded-2xl border p-6">
|
||||||
|
<FieldGroup>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Scheme name"
|
||||||
|
description="Optional. If left blank, the name is created from the offer."
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Soap — buy 10 get 1 free (optional)"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">What kind of offer is it?</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{RULE_ORDER.map((type) => {
|
||||||
|
const selected = ruleType === type
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
disabled={isEdit && !selected}
|
||||||
|
onClick={() => form.setValue('ruleType', type, { shouldValidate: false })}
|
||||||
|
className={`rounded-xl border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||||
|
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-foreground">{RULE_META[type].label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{RULE_META[type].description}</p>
|
||||||
|
<p className="mt-1 text-xs italic text-muted-foreground">e.g. {RULE_META[type].example}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isEdit && <p className="text-xs text-muted-foreground">The kind of offer can't be changed after it is created.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Buy & get free ─────────────────────────────────────────── */}
|
||||||
|
{ruleType === 'BuyXGetY' && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">The store buys</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="triggerItemId"
|
||||||
|
label="Product"
|
||||||
|
render={({ field, invalid }) =>
|
||||||
|
itemSelect(field.value as string, (id) => {
|
||||||
|
field.onChange(id)
|
||||||
|
if (!form.getValues('freeItemId')) form.setValue('freeItemId', id)
|
||||||
|
}, 'Select product…', invalid)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="triggerQty"
|
||||||
|
label="Quantity"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">…and gets free</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeItemId"
|
||||||
|
label="Free product"
|
||||||
|
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeQty"
|
||||||
|
label="Free quantity"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Combo gift ─────────────────────────────────────────────── */}
|
||||||
|
{ruleType === 'FreeBundle' && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-foreground">The store must buy all of these</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => bundle.append({ itemId: '', qty: 1 })}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
Add product
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{bundle.fields.map((row, index) => (
|
||||||
|
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_7rem_auto]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`bundle.${index}.itemId`}
|
||||||
|
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`bundle.${index}.qty`}
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bundle.fields.length === 1}
|
||||||
|
onClick={() => bundle.remove(index)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{form.formState.errors.bundle?.message && (
|
||||||
|
<p className="text-xs text-destructive">{form.formState.errors.bundle.message}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium text-foreground">…and gets this free</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeItemId"
|
||||||
|
label="Free product"
|
||||||
|
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="freeQty"
|
||||||
|
label="Free quantity"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Flat discount ──────────────────────────────────────────── */}
|
||||||
|
{ruleType === 'FlatPercent' && (
|
||||||
|
<div className="rounded-xl border p-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="discountPercent"
|
||||||
|
label="Discount on the whole order (%)"
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Spend more, save more ──────────────────────────────────── */}
|
||||||
|
{ruleType === 'SlabDiscount' && (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-foreground">Discount levels</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => tiers.append({ minOrderAmount: 0, discountPercent: 0 })}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
Add level
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{tiers.fields.map((row, index) => (
|
||||||
|
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`tiers.${index}.minOrderAmount`}
|
||||||
|
label={index === 0 ? 'When the order reaches (Rs.)' : undefined}
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 0 })}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`tiers.${index}.discountPercent`}
|
||||||
|
label={index === 0 ? 'Discount (%)' : undefined}
|
||||||
|
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={index === 0 ? 'mt-6' : ''}
|
||||||
|
disabled={tiers.fields.length === 1}
|
||||||
|
onClick={() => tiers.remove(index)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{form.formState.errors.tiers?.message && (
|
||||||
|
<p className="text-xs text-destructive">{form.formState.errors.tiers.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-muted/50 px-4 py-3 text-sm">
|
||||||
|
<span className="font-medium text-foreground">Summary: </span>
|
||||||
|
<span className="text-muted-foreground">{preview}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dateRangeFrom"
|
||||||
|
label="Starts on"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||||
|
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dateRangeTo"
|
||||||
|
label="Ends on"
|
||||||
|
description="The offer still applies on this day."
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||||
|
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isActive"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="isActive" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||||
|
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">Where does it apply? <span className="font-normal text-muted-foreground">(optional)</span></p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="regionId"
|
||||||
|
label="Region"
|
||||||
|
description="Leave on All Regions to offer it everywhere."
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={(x) => field.onChange(x ?? '')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All Regions" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">All Regions</SelectItem>
|
||||||
|
{regionsList.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="eligibleSegment"
|
||||||
|
label="Store segment"
|
||||||
|
description="Optional, e.g. Gold. Empty = every store."
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={(field.value as string) ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="All segments"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{!readOnly && <div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={goBack}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
||||||
|
|
||||||
|
// Plain-language names for the four rule types, so users don't need to know the technical names.
|
||||||
|
export const RULE_META: Record<SchemeRuleType, { label: string; description: string; example: string }> = {
|
||||||
|
BuyXGetY: {
|
||||||
|
label: 'Buy & get free',
|
||||||
|
description: 'The store buys a quantity of a product and gets free units.',
|
||||||
|
example: 'Buy 10 Soap, get 1 Soap free',
|
||||||
|
},
|
||||||
|
FreeBundle: {
|
||||||
|
label: 'Combo gift',
|
||||||
|
description: 'The store buys a set of products together and gets a free gift.',
|
||||||
|
example: 'Buy Soap + Shampoo, get a Toothbrush free',
|
||||||
|
},
|
||||||
|
FlatPercent: {
|
||||||
|
label: 'Flat discount',
|
||||||
|
description: 'A fixed percentage off the whole order.',
|
||||||
|
example: '10% off every order',
|
||||||
|
},
|
||||||
|
SlabDiscount: {
|
||||||
|
label: 'Spend more, save more',
|
||||||
|
description: 'The discount grows as the order value reaches higher levels.',
|
||||||
|
example: '5% off above Rs. 5,000, 8% off above Rs. 10,000',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlabTierConfig {
|
||||||
|
minOrderAmount: number
|
||||||
|
discountPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRuleConfig(scheme: Pick<SchemeResponse, 'ruleType' | 'ruleConfig'>) {
|
||||||
|
try {
|
||||||
|
const cfg = scheme.ruleConfig ? JSON.parse(scheme.ruleConfig) : null
|
||||||
|
if (scheme.ruleType === 'FlatPercent') return { discountPercent: Number(cfg?.discountPercent) || 0, tiers: [] as SlabTierConfig[] }
|
||||||
|
if (scheme.ruleType === 'SlabDiscount') return { discountPercent: 0, tiers: (cfg?.tiers ?? []) as SlabTierConfig[] }
|
||||||
|
} catch {
|
||||||
|
// Malformed config — fall through to empty.
|
||||||
|
}
|
||||||
|
return { discountPercent: 0, tiers: [] as SlabTierConfig[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const money = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||||
|
|
||||||
|
/** One-sentence description of what a scheme does, built from its saved rule. */
|
||||||
|
export function describeScheme(scheme: SchemeResponse): string {
|
||||||
|
switch (scheme.ruleType) {
|
||||||
|
case 'BuyXGetY':
|
||||||
|
return `Buy ${scheme.triggerQty ?? '?'} ${scheme.triggerItemName ?? 'item'}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
|
||||||
|
case 'FreeBundle': {
|
||||||
|
const parts = scheme.bundleComponents.map((c) => `${c.qty} ${c.itemName}`).join(' + ') || 'items'
|
||||||
|
return `Buy ${parts}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
|
||||||
|
}
|
||||||
|
case 'FlatPercent':
|
||||||
|
return `${parseRuleConfig(scheme).discountPercent}% off the order`
|
||||||
|
case 'SlabDiscount': {
|
||||||
|
const tiers = [...parseRuleConfig(scheme).tiers].sort((a, b) => a.minOrderAmount - b.minOrderAmount)
|
||||||
|
return tiers.length ? tiers.map((t) => `${t.discountPercent}% off from ${money(t.minOrderAmount)}`).join(', ') : 'No levels set'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
|
||||||
import { FormField } from '@/components/shared/form-field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { schemes as schemesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
|
||||||
import type { SchemeResponse, RegionResponse } from '@/types/api'
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
|
|
||||||
regionId: z.string().optional(),
|
|
||||||
dateRangeFrom: z.string().min(1, 'Required'),
|
|
||||||
dateRangeTo: z.string().min(1, 'Required'),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
interface SchemeSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
scheme: SchemeResponse | null
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SchemeSheet({ open, onClose, scheme, onSuccess }: SchemeSheetProps) {
|
|
||||||
const isEdit = !!scheme
|
|
||||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
ruleType: 'FlatPercent',
|
|
||||||
regionId: '',
|
|
||||||
dateRangeFrom: '',
|
|
||||||
dateRangeTo: '',
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
form.reset(
|
|
||||||
scheme
|
|
||||||
? {
|
|
||||||
name: scheme.name,
|
|
||||||
ruleType: scheme.ruleType,
|
|
||||||
regionId: scheme.regionId ?? '',
|
|
||||||
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
|
|
||||||
dateRangeTo: scheme.dateRangeTo.split('T')[0],
|
|
||||||
isActive: scheme.isActive,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
name: '',
|
|
||||||
ruleType: 'FlatPercent',
|
|
||||||
regionId: '',
|
|
||||||
dateRangeFrom: '',
|
|
||||||
dateRangeTo: '',
|
|
||||||
isActive: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [open, scheme])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
regionsApi.list({ pageSize: 100 })
|
|
||||||
.then((res) => setRegionsList(getPaginatedItems(res)))
|
|
||||||
.catch(() => {
|
|
||||||
toast.add({ title: 'Failed to load regions', type: 'error' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
name: values.name,
|
|
||||||
ruleType: values.ruleType,
|
|
||||||
regionId: values.regionId || null,
|
|
||||||
dateRangeFrom: values.dateRangeFrom,
|
|
||||||
dateRangeTo: values.dateRangeTo,
|
|
||||||
isActive: values.isActive,
|
|
||||||
}
|
|
||||||
if (isEdit) {
|
|
||||||
await schemesApi.update(scheme!.id, body)
|
|
||||||
toast.add({ title: 'Scheme updated', type: 'success' })
|
|
||||||
} else {
|
|
||||||
await schemesApi.create(body)
|
|
||||||
toast.add({ title: 'Scheme created', type: 'success' })
|
|
||||||
}
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{isEdit ? 'Edit Scheme' : 'Add Scheme'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
|
||||||
<FieldGroup>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
label="Name"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="e.g. Buy 2 Get 1 Free"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="ruleType"
|
|
||||||
label="Rule Type"
|
|
||||||
required
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
value={field.value as string}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select rule type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="BuyXGetY">BuyXGetY</SelectItem>
|
|
||||||
<SelectItem value="SlabDiscount">SlabDiscount</SelectItem>
|
|
||||||
<SelectItem value="FlatPercent">FlatPercent</SelectItem>
|
|
||||||
<SelectItem value="FreeBundle">FreeBundle</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="regionId"
|
|
||||||
label="Region"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
value={field.value as string}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="All Regions" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">All Regions</SelectItem>
|
|
||||||
{regionsList.map((r) => (
|
|
||||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="dateRangeFrom"
|
|
||||||
label="Date Range From"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="dateRangeFrom"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
type="date"
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="dateRangeTo"
|
|
||||||
label="Date Range To"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="dateRangeTo"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
type="date"
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="isActive"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isActive"
|
|
||||||
checked={field.value as boolean}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import type { SchemeResponse } from '@/types/api'
|
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
|
||||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
|
||||||
|
|
||||||
interface SchemeViewSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
scheme: SchemeResponse | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
|
|
||||||
<span className="text-muted-foreground">{label}</span>
|
|
||||||
<span className="font-medium text-right">{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) {
|
|
||||||
if (!scheme) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Scheme Details</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Row label="Name" value={scheme.name} />
|
|
||||||
<Row label="Rule Type" value={<Badge>{scheme.ruleType}</Badge>} />
|
|
||||||
<Row label="Region" value={scheme.regionName ?? 'All Regions'} />
|
|
||||||
<Row label="Eligible Segment" value={scheme.eligibleSegment ?? 'All Segments'} />
|
|
||||||
<Row label="Status" value={<Badge variant={scheme.isActive ? 'default' : 'secondary'}>{scheme.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
|
||||||
<Row label="Date Range" value={`${fmtDate(scheme.dateRangeFrom)} → ${fmtDate(scheme.dateRangeTo)}`} />
|
|
||||||
<Row label="Agent Limit Exempt" value={scheme.agentLimitExempt ? 'Yes' : 'No'} />
|
|
||||||
{scheme.baselineExpectedSales > 0 && (
|
|
||||||
<Row label="Baseline Expected Sales" value={`Rs. ${scheme.baselineExpectedSales.toLocaleString()}`} />
|
|
||||||
)}
|
|
||||||
{scheme.ruleConfig && <Row label="Rule Config" value={<span className="font-mono text-xs">{scheme.ruleConfig}</span>} />}
|
|
||||||
{scheme.triggerItemName && (
|
|
||||||
<Row label="Trigger" value={`${scheme.triggerQty} × ${scheme.triggerItemName}`} />
|
|
||||||
)}
|
|
||||||
{scheme.freeItemName && (
|
|
||||||
<Row label="Free Goods" value={`${scheme.freeQty} × ${scheme.freeItemName}`} />
|
|
||||||
)}
|
|
||||||
<Row label="Created" value={fmtDate(scheme.createdAt)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scheme.bundleComponents.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-sm font-medium text-foreground">Bundle Components</p>
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Item</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium">Qty</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{scheme.bundleComponents.map((c) => (
|
|
||||||
<tr key={c.itemId} className="border-t">
|
|
||||||
<td className="px-3 py-2">{c.itemName} <span className="ml-1 text-xs text-muted-foreground">({c.sku})</span></td>
|
|
||||||
<td className="px-3 py-2 text-right">{c.qty}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
@@ -12,8 +13,7 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||||
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
||||||
|
|
||||||
import { SchemeSheet } from './scheme-sheet'
|
import { RULE_META, describeScheme } from './scheme-labels'
|
||||||
import { SchemeViewSheet } from './scheme-view-sheet'
|
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
const fmtDate = (v: string) =>
|
||||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
@@ -26,14 +26,11 @@ const RULE_TYPE_VARIANT: Record<SchemeRuleType, 'default' | 'secondary'> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SchemesClient() {
|
export function SchemesClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<SchemeResponse[]>([])
|
const [data, setData] = useState<SchemeResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const [editing, setEditing] = useState<SchemeResponse | null>(null)
|
|
||||||
const [viewing, setViewing] = useState<SchemeResponse | null>(null)
|
|
||||||
const [viewOpen, setViewOpen] = useState(false)
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -52,18 +49,15 @@ export function SchemesClient() {
|
|||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null)
|
router.push('/pricing/schemes/new')
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(scheme: SchemeResponse) {
|
function openEdit(scheme: SchemeResponse) {
|
||||||
setEditing(scheme)
|
router.push(`/pricing/schemes/${scheme.id}/edit`)
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openView(scheme: SchemeResponse) {
|
function openView(scheme: SchemeResponse) {
|
||||||
setViewing(scheme)
|
router.push(`/pricing/schemes/${scheme.id}`)
|
||||||
setViewOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(scheme: SchemeResponse) {
|
async function handleDelete(scheme: SchemeResponse) {
|
||||||
@@ -91,11 +85,14 @@ export function SchemesClient() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ruleType',
|
key: 'ruleType',
|
||||||
title: 'Rule Type',
|
title: 'Offer',
|
||||||
fields: ['ruleType'],
|
fields: ['ruleType'],
|
||||||
width: 160,
|
minWidth: 280,
|
||||||
cellRender: (row) => (
|
cellRender: (row) => (
|
||||||
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
|
<div className="flex flex-col gap-1">
|
||||||
|
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]} className="w-fit">{RULE_META[row.ruleType].label}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">{describeScheme(row)}</span>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -177,18 +174,6 @@ export function SchemesClient() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SchemeSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onClose={() => setSheetOpen(false)}
|
|
||||||
scheme={editing}
|
|
||||||
onSuccess={fetchData}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SchemeViewSheet
|
|
||||||
open={viewOpen}
|
|
||||||
onClose={() => setViewOpen(false)}
|
|
||||||
scheme={viewing}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user