Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f58e6b73 | |||
| e13ec0f109 | |||
| 2c39b34c5f | |||
| 99832df134 | |||
| 2be9d9b396 | |||
| a12e170205 | |||
| fdfca7cf5e | |||
| e84ce29064 | |||
| 2be2928ad1 | |||
| 872fd42c4a | |||
| 13fdb28ec7 | |||
| 39ad424d21 | |||
| d79c5e55d0 | |||
| 50d3113c28 | |||
| 34d33571de | |||
| 0f575370d4 | |||
| e4061fa94c | |||
| 5f8bc70174 | |||
| f2d5c9f50a | |||
| 9e39981c9a | |||
| bd8b2d8b44 | |||
| f26557a66e | |||
| 91959913a8 | |||
| 48f607d623 | |||
| ee9a5385bf | |||
| 82be251878 | |||
| 209ee7da2d | |||
| 6b9f27573b | |||
| a0df2a1bbf | |||
| d1c3d4e22f | |||
| 138bf2f925 | |||
| a80512994b | |||
| 1b4461dc12 | |||
| 2de9e5e4ad |
@@ -0,0 +1,209 @@
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Distribution.Tests.Services;
|
||||
|
||||
public class NotificationServiceTests
|
||||
{
|
||||
private readonly Mock<INotificationRepository> _repo = new();
|
||||
private readonly Mock<IUnitOfWork> _uow = new();
|
||||
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
|
||||
private readonly Mock<IHubClients> _clients = new();
|
||||
private readonly Mock<IClientProxy> _proxy = new();
|
||||
private readonly NotificationService _sut;
|
||||
|
||||
public NotificationServiceTests()
|
||||
{
|
||||
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
|
||||
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
|
||||
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
|
||||
|
||||
_sut = new NotificationService(
|
||||
_repo.Object,
|
||||
new NotificationRecipientResolver(_repo.Object),
|
||||
_hub.Object,
|
||||
_uow.Object,
|
||||
NullLogger<NotificationService>.Instance);
|
||||
}
|
||||
|
||||
private static NotificationArgs Args(Guid? actor = null) =>
|
||||
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
|
||||
|
||||
// Registry
|
||||
|
||||
[Fact]
|
||||
public void EveryNotificationTypeConstant_HasADefinition()
|
||||
{
|
||||
var constants = typeof(NotificationTypes)
|
||||
.GetFields()
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(constants);
|
||||
Assert.Equal(constants.Count, constants.Distinct().Count());
|
||||
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
|
||||
}
|
||||
|
||||
// NotifyAsync
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
|
||||
{
|
||||
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
|
||||
|
||||
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
|
||||
{
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
|
||||
|
||||
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_ExcludesTheActingUser()
|
||||
{
|
||||
var actor = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||
[RecipientRule.Users(actor, other)], Args(actor));
|
||||
|
||||
Assert.NotNull(saved);
|
||||
var recipient = Assert.Single(saved!.Recipients);
|
||||
Assert.Equal(other, recipient.FK_UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
|
||||
{
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
|
||||
|
||||
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_clients.Verify(c => c.User(a.ToString()), Times.Once);
|
||||
_clients.Verify(c => c.User(b.ToString()), Times.Once);
|
||||
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
|
||||
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
|
||||
{
|
||||
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
|
||||
|
||||
var ex = await Record.ExceptionAsync(() =>
|
||||
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||
|
||||
Assert.Null(ex);
|
||||
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
|
||||
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
|
||||
{
|
||||
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("socket gone"));
|
||||
|
||||
var ex = await Record.ExceptionAsync(() =>
|
||||
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||
|
||||
Assert.Null(ex);
|
||||
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
|
||||
}
|
||||
|
||||
// Recipient rules
|
||||
|
||||
[Fact]
|
||||
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
|
||||
{
|
||||
var both = Guid.NewGuid();
|
||||
var permissionOnly = Guid.NewGuid();
|
||||
var scopeOnly = Guid.NewGuid();
|
||||
var storeId = Guid.NewGuid();
|
||||
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
|
||||
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
|
||||
|
||||
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
|
||||
|
||||
Assert.Equal([both], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
var supervisor = Guid.NewGuid();
|
||||
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||
|
||||
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
|
||||
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||
|
||||
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
|
||||
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
|
||||
{
|
||||
var storeId = Guid.NewGuid();
|
||||
var owner = Guid.NewGuid();
|
||||
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||
|
||||
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
|
||||
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||
|
||||
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
|
||||
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
|
||||
{
|
||||
Models.Notification? saved = null;
|
||||
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||
.Callback<Models.Notification>(n => saved = n)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
|
||||
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
|
||||
|
||||
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_DeduplicatesUsersAcrossRules()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
|
||||
|
||||
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
|
||||
|
||||
Assert.Equal([user], ids);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||
public DbSet<Invoice> Invoices { get; set; }
|
||||
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||||
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
|
||||
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
|
||||
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||
public DbSet<User> Users { get; set; }
|
||||
@@ -66,12 +69,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||
public DbSet<SysConfig> SysConfigs { get; set; }
|
||||
public DbSet<BankAccount> BankAccounts { get; set; }
|
||||
public DbSet<GlPostingLog> GlPostingLogs { get; set; }
|
||||
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
||||
public DbSet<ChequePage> ChequePages { get; set; }
|
||||
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
||||
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
||||
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
||||
public DbSet<Notification> Notifications { get; set; }
|
||||
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
|
||||
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
@@ -174,6 +178,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(i => i.FK_ShopPOId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.Invoice)
|
||||
.WithMany(inv => inv.Items)
|
||||
.HasForeignKey(i => i.FK_InvoiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.ShopPOItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<InvoiceItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersion>()
|
||||
.HasOne(v => v.Invoice)
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.FK_InvoiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersion>()
|
||||
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.InvoiceVersion)
|
||||
.WithMany(v => v.Items)
|
||||
.HasForeignKey(i => i.FK_InvoiceVersionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.ShopPOItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<InvoiceVersionItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasMany(i => i.Payments)
|
||||
.WithOne(p => p.Invoice)
|
||||
@@ -583,16 +635,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasIndex(r => r.FK_RegionId)
|
||||
.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 ---
|
||||
|
||||
modelBuilder.Entity<SysConfig>()
|
||||
@@ -719,6 +761,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||
.HasIndex(a => a.FK_GoodsReceiptId);
|
||||
|
||||
// --- Notifications ---
|
||||
|
||||
modelBuilder.Entity<Notification>()
|
||||
.HasIndex(n => new { n.EntityType, n.EntityId });
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasOne(r => r.Notification)
|
||||
.WithMany(n => n.Recipients)
|
||||
.HasForeignKey(r => r.FK_NotificationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasOne(r => r.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FK_UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasIndex(r => new { r.FK_UserId, r.IsRead });
|
||||
|
||||
modelBuilder.Entity<NotificationRecipient>()
|
||||
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
|
||||
.IsUnique();
|
||||
|
||||
// --- StoreCreditMovement ---
|
||||
|
||||
modelBuilder.Entity<StoreCreditMovement>()
|
||||
|
||||
@@ -68,8 +68,6 @@ public static class RoleAndPermissionSeed
|
||||
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
||||
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||
private static readonly Guid PermFinanceReportView = new("20000000-0000-0000-0000-000000000051");
|
||||
private static readonly Guid PermGlManage = new("20000000-0000-0000-0000-000000000052");
|
||||
|
||||
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 = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = 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 }
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
// 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 = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermFinanceReportView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGlManage, CreatedAt = SeedDate, IsActive = true }
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -8,8 +7,7 @@ namespace Distribution.Features.ChequeBook;
|
||||
public class ChequeBookService(
|
||||
IChequeBookRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow,
|
||||
GlPostingService gl)
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string DefaultCurrency = "LKR";
|
||||
|
||||
@@ -173,7 +171,6 @@ public class ChequeBookService(
|
||||
|
||||
repo.UpdatePage(page);
|
||||
await uow.CommitAsync(ct);
|
||||
await gl.PostIssuedChequeAsync(page, request.Action, ct);
|
||||
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 SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||
|
||||
public record SalesKpis(
|
||||
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||
|
||||
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
||||
|
||||
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
|
||||
|
||||
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
|
||||
|
||||
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
|
||||
|
||||
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
||||
|
||||
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
||||
|
||||
public record SalesSummaryResponse(
|
||||
DateTime From, DateTime To, string Granularity,
|
||||
SalesKpiComparison Kpis, List<TrendPoint> Trend,
|
||||
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
|
||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||
|
||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||
|
||||
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
public Guid RepId { get; set; }
|
||||
}
|
||||
|
||||
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
|
||||
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
|
||||
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
|
||||
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q =
|
||||
from li in db.ShopPOItems.AsNoTracking()
|
||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||
where inv.Status == InvoiceStatus.Finalized
|
||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
|
||||
from li in db.InvoiceItems.AsNoTracking()
|
||||
where li.IsActive && !li.IsRemoved
|
||||
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
|
||||
select new SoldLine
|
||||
{
|
||||
Date = inv.FinalizedAt!.Value.Date,
|
||||
PoId = li.FK_ShopPOId,
|
||||
Value = li.QuantityFulfilled * li.UnitPrice,
|
||||
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
|
||||
Date = li.Invoice.FinalizedAt!.Value.Date,
|
||||
PoId = li.Invoice.FK_ShopPOId,
|
||||
Value = li.Quantity * li.UnitPrice,
|
||||
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
|
||||
CategoryId = li.Item.FK_CategoryId,
|
||||
CategoryName = li.Item.Category.Name,
|
||||
RegionId = li.ShopPO.Store.Route.FK_RegionId,
|
||||
RegionName = li.ShopPO.Store.Route.Region.Name,
|
||||
RepId = li.ShopPO.RaisedById,
|
||||
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
|
||||
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
|
||||
RepId = li.Invoice.ShopPO.RaisedById,
|
||||
};
|
||||
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
||||
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
||||
@@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
|
||||
var q = db.SaleReturnItems.AsNoTracking()
|
||||
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
|
||||
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
|
||||
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
|
||||
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
return q;
|
||||
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
||||
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
||||
return new SalesTotals(gross, returns, orders, costedSales, cost);
|
||||
return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
|
||||
}
|
||||
|
||||
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
|
||||
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
|
||||
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
|
||||
{
|
||||
if (f.CategoryId.HasValue) return 0m;
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q = db.Invoices.AsNoTracking()
|
||||
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||
}
|
||||
|
||||
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
|
||||
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
|
||||
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
|
||||
private const int OverdueDays = 30;
|
||||
|
||||
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
|
||||
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
|
||||
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue)
|
||||
{
|
||||
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
}
|
||||
|
||||
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||
|
||||
var rows = await invs.Select(i => new
|
||||
{
|
||||
i.FinalizedAt,
|
||||
i.TotalAmount,
|
||||
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||
.Sum(p => (decimal?)p.Amount) ?? 0m,
|
||||
}).ToListAsync();
|
||||
|
||||
var balances = rows
|
||||
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
|
||||
.ToList();
|
||||
return new ReceivablesTotals(collected,
|
||||
balances.Sum(b => b.Balance),
|
||||
balances.Where(b => b.Due < end).Sum(b => b.Balance));
|
||||
}
|
||||
|
||||
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
|
||||
public class DashboardReportService(IDashboardReportRepository repo)
|
||||
{
|
||||
public const int MaxRangeDays = 366;
|
||||
private const int DailyGranularityMaxDays = 62;
|
||||
private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
|
||||
|
||||
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
||||
{
|
||||
@@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
|
||||
private static SalesKpis ToKpis(SalesTotals t)
|
||||
{
|
||||
var net = t.GrossSales - t.ReturnValue;
|
||||
var net = t.GrossSales - t.ReturnValue - t.Discounts;
|
||||
var gp = t.CostedSales - t.CostOfSales;
|
||||
return new SalesKpis(
|
||||
t.GrossSales, t.ReturnValue, net, t.Orders,
|
||||
t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
|
||||
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
||||
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
||||
}
|
||||
|
||||
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
|
||||
new(r.Collected, r.Outstanding, r.Overdue,
|
||||
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
|
||||
|
||||
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||
{
|
||||
var days = (f.To - f.From).Days + 1;
|
||||
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
|
||||
var current = await repo.GetSalesTotalsAsync(f);
|
||||
var previous = await repo.GetSalesTotalsAsync(prev);
|
||||
var currentCash = await repo.GetReceivablesAsync(f);
|
||||
var previousCash = await repo.GetReceivablesAsync(prev);
|
||||
var sales = await repo.GetDailySalesAsync(f);
|
||||
var returns = await repo.GetDailyReturnsAsync(f);
|
||||
|
||||
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
return new SalesSummaryResponse(
|
||||
f.From, f.To, daily ? "day" : "month",
|
||||
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
||||
new CashKpiComparison(
|
||||
ToCash(currentCash, current.GrossSales - current.Discounts),
|
||||
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
|
||||
trend,
|
||||
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
||||
|
||||
@@ -5,6 +5,7 @@ public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
||||
public interface IDashboardReportRepository
|
||||
{
|
||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.LoadingEvent;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
|
||||
IShopPORepository shopPORepo,
|
||||
ILoadingSheetRepository loadingSheetRepo,
|
||||
StockMover mover,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string DeliveriesLink = "/operations/deliveries";
|
||||
|
||||
// Route owner + managers whose scope covers the store being delivered to.
|
||||
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
|
||||
[
|
||||
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
|
||||
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
|
||||
];
|
||||
|
||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||
new(t.Id,
|
||||
t.FK_RideId,
|
||||
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
if (!fullyDelivered && shopPO.Store is not null)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.DeliveryTaskPartiallyDelivered,
|
||||
DeliveryRecipients(shopPO.FK_StoreId),
|
||||
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
|
||||
ct);
|
||||
|
||||
var saved = await repo.GetByIdAsync(taskId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
if (task.ShopPO?.Store is not null)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.DeliveryTaskFailed,
|
||||
DeliveryRecipients(task.ShopPO.FK_StoreId),
|
||||
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
|
||||
ct);
|
||||
|
||||
return ToResponse(task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.Stock;
|
||||
using Distribution.Features.SysConfig;
|
||||
@@ -14,8 +13,7 @@ public class GrnService(
|
||||
IUnitOfWork uow,
|
||||
Distribution.Features.Item.IItemRepository itemRepo,
|
||||
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
||||
Distribution.Features.SysConfig.SysConfigService sysConfig,
|
||||
GlPostingService gl)
|
||||
Distribution.Features.SysConfig.SysConfigService sysConfig)
|
||||
{
|
||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
||||
{
|
||||
@@ -484,7 +482,6 @@ public class GrnService(
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||
await gl.PostGrnAsync(saved!, ct);
|
||||
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<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||
Task AddAsync(Models.Invoice invoice);
|
||||
Task<Models.Item?> GetItemAsync(Guid itemId);
|
||||
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
|
||||
Task AddVersionAsync(InvoiceVersion version);
|
||||
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
|
||||
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
|
||||
void Update(Models.Invoice invoice);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,45 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/versions")]
|
||||
public async Task<IActionResult> GetVersions(Guid id)
|
||||
{
|
||||
var result = await service.GetVersionsAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items")]
|
||||
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.EditedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/print")]
|
||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -2,11 +2,36 @@
|
||||
|
||||
namespace Distribution.Features.Invoice;
|
||||
|
||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
||||
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
|
||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
|
||||
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null);
|
||||
|
||||
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);
|
||||
|
||||
public record InvoiceResponse(
|
||||
Guid Id,
|
||||
Guid ShopPOId,
|
||||
@@ -20,8 +45,10 @@ public record InvoiceResponse(
|
||||
DateTime? FinalizedAt,
|
||||
Guid? FinalizedBy,
|
||||
decimal TotalAmount,
|
||||
decimal DiscountAmount,
|
||||
decimal PaidAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
DateTime CreatedAt,
|
||||
List<InvoiceItemResponse> Items);
|
||||
|
||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||
|
||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
|
||||
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
|
||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||
|
||||
@@ -49,9 +51,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
|
||||
await context.SchemeRedemptions
|
||||
.Where(r => r.FK_ShopPOId == shopPOId)
|
||||
.SumAsync(r => r.DiscountAmount) ?? 0m;
|
||||
|
||||
public async Task AddAsync(Models.Invoice invoice) =>
|
||||
await context.Invoices.AddAsync(invoice);
|
||||
|
||||
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
|
||||
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
|
||||
|
||||
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
|
||||
await context.InvoiceItems.AddRangeAsync(items);
|
||||
|
||||
public async Task AddVersionAsync(InvoiceVersion version) =>
|
||||
await context.InvoiceVersions.AddAsync(version);
|
||||
|
||||
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
|
||||
await context.InvoiceVersions
|
||||
.Where(v => v.FK_InvoiceId == invoiceId)
|
||||
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
|
||||
|
||||
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
|
||||
await context.InvoiceVersions
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
|
||||
.OrderBy(v => v.VersionNumber)
|
||||
.ToListAsync();
|
||||
|
||||
public void Update(Models.Invoice invoice) =>
|
||||
context.Invoices.Update(invoice);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
@@ -8,9 +8,9 @@ namespace Distribution.Features.Invoice;
|
||||
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IShopPORepository shopPoRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||
new(i.Id, i.FK_ShopPOId,
|
||||
@@ -19,9 +19,72 @@ public class InvoiceService(
|
||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||
i.PrintedAt, i.PrintedBy,
|
||||
i.FinalizedAt, i.FinalizedBy,
|
||||
i.TotalAmount,
|
||||
i.TotalAmount, i.DiscountAmount,
|
||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
||||
i.IsActive, i.CreatedAt);
|
||||
i.IsActive, i.CreatedAt,
|
||||
i.Items.Where(x => x.IsActive).Select(x =>
|
||||
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
||||
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
||||
x.Quantity, x.UnitPrice, x.IsRemoved)).ToList());
|
||||
|
||||
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||
|
||||
// TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change
|
||||
// and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks.
|
||||
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||
{
|
||||
var billable = BillableTotal(items);
|
||||
invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable);
|
||||
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||
}
|
||||
|
||||
// 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
|
||||
}).ToList();
|
||||
|
||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
@@ -44,6 +107,123 @@ public class InvoiceService(
|
||||
return new OutstandingPaymentsResponse(count, outstanding);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
||||
{
|
||||
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.ConfirmedAt is null)
|
||||
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
||||
if (po.InvoiceId is not null)
|
||||
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
||||
|
||||
var invoice = new Models.Invoice
|
||||
{
|
||||
FK_ShopPOId = po.Id,
|
||||
Status = InvoiceStatus.Draft,
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||
await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(invoice);
|
||||
await repo.AddItemsAsync(lines);
|
||||
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
||||
po.InvoiceId = invoice.Id;
|
||||
shopPoRepo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(invoice.Id);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Invoice not found");
|
||||
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
|
||||
|
||||
var newLines = new List<InvoiceItem>();
|
||||
|
||||
foreach (var edit in request.Items)
|
||||
{
|
||||
if (edit.UnitPrice is < 0)
|
||||
throw new InvalidOperationException("Unit price cannot be negative");
|
||||
|
||||
if (edit.InvoiceItemId is Guid lineId)
|
||||
{
|
||||
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
|
||||
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
|
||||
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
|
||||
|
||||
item.IsRemoved = edit.IsRemoved;
|
||||
if (!edit.IsRemoved)
|
||||
{
|
||||
ValidateQuantity(item, edit.Quantity);
|
||||
item.Quantity = edit.Quantity;
|
||||
}
|
||||
if (edit.UnitPrice is decimal price) 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)");
|
||||
}
|
||||
|
||||
await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines));
|
||||
|
||||
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 ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||
{
|
||||
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
|
||||
if (quantity < 0 || quantity > ordered)
|
||||
throw new InvalidOperationException(
|
||||
$"Quantity for item {item.Id} must be between 0 and {ordered}");
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
@@ -52,12 +232,21 @@ public class InvoiceService(
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||
|
||||
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
||||
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
||||
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
invoice.Status = InvoiceStatus.Printed;
|
||||
invoice.PrintedAt = DateTime.Now;
|
||||
invoice.PrintedBy = printedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(invoice);
|
||||
@@ -71,35 +260,39 @@ public class InvoiceService(
|
||||
if (invoice.Status != InvoiceStatus.Printed)
|
||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||
|
||||
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||
var adjustments = request.Adjustments ?? [];
|
||||
if (adjustments.Count > 0)
|
||||
|
||||
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
||||
foreach (var adjustment in adjustments)
|
||||
{
|
||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
||||
decimal total = 0;
|
||||
foreach (var item in poItems)
|
||||
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||
|
||||
item.IsRemoved = adjustment.IsRemoved;
|
||||
if (!adjustment.IsRemoved)
|
||||
{
|
||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
||||
|
||||
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
||||
throw new InvalidOperationException(
|
||||
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
||||
|
||||
total += billedQuantity * item.UnitPrice;
|
||||
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||
item.Quantity = adjustment.AdjustedQuantity;
|
||||
}
|
||||
invoice.TotalAmount = total;
|
||||
}
|
||||
|
||||
if (items.Count > 0)
|
||||
await ApplyTotalsAsync(invoice, items);
|
||||
|
||||
invoice.Status = InvoiceStatus.Finalized;
|
||||
invoice.FinalizedAt = DateTime.Now;
|
||||
invoice.FinalizedBy = finalizedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
try
|
||||
{
|
||||
repo.Update(invoice);
|
||||
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await gl.PostInvoiceFinalizedAsync(invoice, ct);
|
||||
|
||||
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.GeneralLedger;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.StoreCredit;
|
||||
@@ -16,8 +15,7 @@ public class PaymentService(
|
||||
IReceivedChequeRepository receivedChequeRepo,
|
||||
IStoreCreditMovementRepository creditRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
||||
@@ -152,13 +150,14 @@ public class PaymentService(
|
||||
|
||||
invoiceRepo.Update(invoice);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Cash/Bank (BankAccount) OR Cheques-in-hand (for Cheque payments)
|
||||
// Credit Accounts Receivable (invoice.Store)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
||||
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||
{
|
||||
var lines =
|
||||
from li in db.ShopPOItems.AsNoTracking()
|
||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||
where inv.Status == InvoiceStatus.Finalized
|
||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
from li in db.InvoiceItems.AsNoTracking()
|
||||
where li.IsActive && !li.IsRemoved
|
||||
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
select li;
|
||||
|
||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||
{
|
||||
var s = f.Search.Trim();
|
||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
||||
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||
});
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
||||
// Distinct orders and stores per item are counted separately: MySQL can't run the correlated
|
||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||
var storePairs = await SoldLines(f)
|
||||
var pairs = await SoldLines(f)
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
||||
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
||||
var orderCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.PoId).Distinct().Count());
|
||||
var storeCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.FK_StoreId).Distinct().Count());
|
||||
|
||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
|
||||
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
||||
SoldLines(f).SumAsync(x => x.Quantity * x.UnitPrice);
|
||||
|
||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||
{
|
||||
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
return await SoldLines(f with { Search = null })
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.GroupBy(l => l.FK_ItemId)
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.Quantity) })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -9,8 +8,7 @@ namespace Distribution.Features.ReceivedCheque;
|
||||
public class ReceivedChequeService(
|
||||
IReceivedChequeRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow,
|
||||
GlPostingService gl)
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
||||
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
||||
@@ -112,7 +110,6 @@ public class ReceivedChequeService(
|
||||
|
||||
repo.Update(cheque);
|
||||
await uow.CommitAsync(ct);
|
||||
await gl.PostReceivedChequeAsync(cheque, request.Action, ct);
|
||||
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.DriverProfile;
|
||||
using Distribution.Infra.UoW;
|
||||
@@ -10,9 +12,14 @@ public class RideService(
|
||||
IRideRepository rideRepo,
|
||||
IDriverProfileRepository driverRepo,
|
||||
IDriverComplianceConfigRepository complianceRepo,
|
||||
IDeliveryTaskRepository taskRepo,
|
||||
StockMover mover,
|
||||
INotificationService notifications,
|
||||
ILogger<RideService> logger,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const string RidesLink = "/operations/rides";
|
||||
|
||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||
{
|
||||
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||
@@ -72,6 +79,12 @@ public class RideService(
|
||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.RideDriverAssigned,
|
||||
[RecipientRule.Users(driver.FK_UserId)],
|
||||
new NotificationArgs(null, "Ride", rideId, RidesLink),
|
||||
ct);
|
||||
|
||||
var saved = await rideRepo.GetByIdAsync(rideId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
@@ -125,9 +138,46 @@ public class RideService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyRideCancelledAsync(ride, request, userId, ct);
|
||||
|
||||
return ToResponse(ride);
|
||||
}
|
||||
|
||||
// A ride has no route of its own; the people to tell are the owners and managers of the routes
|
||||
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
|
||||
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
|
||||
var routeIds = tasks
|
||||
.Where(t => t.ShopPO?.Store is not null)
|
||||
.Select(t => t.ShopPO.Store.FK_RouteId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var recipients = new List<RecipientRule>();
|
||||
foreach (var routeId in routeIds)
|
||||
{
|
||||
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
|
||||
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
|
||||
}
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.RideCancelled,
|
||||
recipients,
|
||||
new NotificationArgs(
|
||||
new { CancelType = request.TypeCode, Reason = request.Reason },
|
||||
"Ride", ride.Id, RidesLink, userId),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||
{
|
||||
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
||||
|
||||
@@ -11,6 +11,7 @@ public interface ISchemeRepository
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||
|
||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||
|
||||
@@ -52,6 +52,10 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||
|
||||
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
|
||||
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
|
||||
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
|
||||
|
||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||
await context.SchemeBundleComponents
|
||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||
|
||||
@@ -383,6 +383,9 @@ public class SchemeService(
|
||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||
|
||||
if (await repo.HasLockedInvoiceAsync(shopPOId))
|
||||
throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed.");
|
||||
|
||||
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||
var eligibility = EvaluateRule(scheme, items);
|
||||
if (!eligibility.Eligible)
|
||||
|
||||
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("confirmed-without-invoice")]
|
||||
public async Task<IActionResult> GetConfirmedWithoutInvoice(
|
||||
[FromQuery] Guid? agentId,
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? shopId)
|
||||
{
|
||||
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
@@ -10,8 +11,17 @@ public class ShopPOService(
|
||||
IShopPORepository repo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
// 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) =>
|
||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
@@ -55,6 +65,13 @@ public class ShopPOService(
|
||||
return ToResponse(po, users);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (request.Items.Count == 0)
|
||||
@@ -168,6 +185,8 @@ public class ShopPOService(
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
|
||||
@@ -198,12 +217,17 @@ public class ShopPOService(
|
||||
try
|
||||
{
|
||||
await invoiceRepo.AddAsync(invoice);
|
||||
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
|
||||
await invoiceRepo.AddItemsAsync(lines);
|
||||
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
|
||||
po.InvoiceId = invoice.Id;
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
|
||||
|
||||
var saved = await repo.GetByIdAsync(id);
|
||||
return await ToResponseWithUserAsync(saved!);
|
||||
}
|
||||
@@ -231,6 +255,8 @@ public class ShopPOService(
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.StoreCredit;
|
||||
@@ -7,7 +8,7 @@ using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo)
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
private const int CreditApproverRoleLevel = 20;
|
||||
@@ -148,6 +149,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
|
||||
store.OutstandingBalance = request.NewBalance;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -160,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Only on the crossing, so repeated updates while over the limit don't re-alert.
|
||||
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
|
||||
await notifications.NotifyAsync(
|
||||
NotificationTypes.CreditLimitBreached,
|
||||
[
|
||||
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
|
||||
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
|
||||
],
|
||||
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
|
||||
ct);
|
||||
|
||||
return ToResponse(store);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -9,6 +10,7 @@ public class SupplierClaimService(
|
||||
ISupplierClaimRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IGrnRepository grnRepo,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||
@@ -149,9 +151,19 @@ public class SupplierClaimService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
|
||||
// Tells whoever raised the claim how it was decided (never the person who decided it).
|
||||
private Task NotifyDecisionAsync(string type, SupplierClaim claim, Guid decidedBy, CancellationToken ct) =>
|
||||
notifications.NotifyAsync(
|
||||
type,
|
||||
[RecipientRule.Users(claim.RaisedByUserId)],
|
||||
new NotificationArgs(null, "SupplierClaim", claim.Id, "/returns-claims/supplier-claims", decidedBy),
|
||||
ct);
|
||||
|
||||
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id)
|
||||
@@ -172,6 +184,8 @@ public class SupplierClaimService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Supplier;
|
||||
using Distribution.Infra.Authorization;
|
||||
@@ -18,8 +17,7 @@ public class SupplierPaymentService(
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IChequeBookRepository chequeBookRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
||||
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
||||
@@ -163,13 +161,14 @@ public class SupplierPaymentService(
|
||||
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Accounts Payable (supplier)
|
||||
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +98,6 @@ namespace Distribution.Migrations
|
||||
table: "DeliveryTasks",
|
||||
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(
|
||||
name: "IX_DeliveryTasks_FK_OrderId",
|
||||
table: "DeliveryTasks",
|
||||
|
||||
+163
-211
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDBContext))]
|
||||
[Migration("20261020082853_GlIntegration")]
|
||||
partial class GlIntegration
|
||||
[Migration("20261020075213_AddNotifications")]
|
||||
partial class AddNotifications
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -59,9 +59,6 @@ namespace Distribution.Migrations
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("varchar(10)");
|
||||
|
||||
b.Property<string>("GlAccountCode")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1335,6 +1281,93 @@ namespace Distribution.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1868,22 +1901,6 @@ namespace Distribution.Migrations
|
||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Description = "Update system configuration settings",
|
||||
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_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2609,7 +2626,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2617,7 +2634,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2625,7 +2642,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2633,7 +2650,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2641,7 +2658,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2649,7 +2666,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-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),
|
||||
Id = new Guid("16ad892c-b785-4872-a426-1015f32c90b9"),
|
||||
Id = new Guid("41c7bd2c-0253-4315-8594-811aec35f15c"),
|
||||
IsActive = true
|
||||
},
|
||||
new
|
||||
@@ -2657,7 +2674,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2665,7 +2682,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2673,7 +2690,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2681,7 +2698,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2689,7 +2706,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2697,7 +2714,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2705,7 +2722,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2713,7 +2730,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2721,7 +2738,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2729,7 +2746,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2737,7 +2754,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2745,7 +2762,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2753,7 +2770,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2761,7 +2778,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2769,7 +2786,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2777,7 +2794,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2785,7 +2802,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2793,7 +2810,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2801,7 +2818,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2809,7 +2826,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2817,7 +2834,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2825,7 +2842,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2833,7 +2850,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2841,7 +2858,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2849,7 +2866,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2857,7 +2874,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2865,7 +2882,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2873,7 +2890,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2881,7 +2898,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2889,7 +2906,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2897,7 +2914,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2905,7 +2922,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2913,7 +2930,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2921,7 +2938,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2929,7 +2946,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2937,7 +2954,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2945,7 +2962,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2953,7 +2970,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2961,7 +2978,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2969,7 +2986,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2977,7 +2994,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2985,7 +3002,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2993,23 +3010,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Id = new Guid("93c036e4-d811-4915-b73c-1cb358f17065"),
|
||||
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"),
|
||||
Id = new Guid("be5ff487-33d9-4c63-81f4-fd7f6553c5bf"),
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -5104,6 +5057,25 @@ namespace Distribution.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
||||
@@ -5536,31 +5508,6 @@ namespace Distribution.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Category", "Category")
|
||||
@@ -5821,6 +5768,11 @@ namespace Distribution.Migrations
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||
{
|
||||
b.Navigation("Recipients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||
{
|
||||
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");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,6 @@ namespace Distribution.Migrations
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("varchar(10)");
|
||||
|
||||
b.Property<string>("GlAccountCode")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -902,6 +848,9 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal>("DiscountAmount")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<Guid>("FK_ShopPOId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -941,6 +890,7 @@ namespace Distribution.Migrations
|
||||
Id = new Guid("34500000-0000-0000-0000-000000000001"),
|
||||
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified),
|
||||
CreatedBy = new Guid("29000000-0000-0000-0000-000000000002"),
|
||||
DiscountAmount = 0m,
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
FinalizedBy = new Guid("29000000-0000-0000-0000-000000000001"),
|
||||
@@ -956,6 +906,7 @@ namespace Distribution.Migrations
|
||||
Id = new Guid("34500000-0000-0000-0000-000000000002"),
|
||||
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
CreatedBy = new Guid("29000000-0000-0000-0000-000000000003"),
|
||||
DiscountAmount = 0m,
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
||||
IsActive = true,
|
||||
PaymentStatus = 1,
|
||||
@@ -966,6 +917,123 @@ 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_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_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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1332,6 +1400,93 @@ namespace Distribution.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1865,22 +2020,6 @@ namespace Distribution.Migrations
|
||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Description = "Update system configuration settings",
|
||||
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 +2737,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2606,7 +2745,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2614,7 +2753,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2622,7 +2761,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2630,7 +2769,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2638,7 +2777,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2646,7 +2785,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-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),
|
||||
Id = new Guid("16ad892c-b785-4872-a426-1015f32c90b9"),
|
||||
Id = new Guid("ae20b416-a508-4ffc-92a1-e98aaeadb8b6"),
|
||||
IsActive = true
|
||||
},
|
||||
new
|
||||
@@ -2654,7 +2793,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2662,7 +2801,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2670,7 +2809,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2678,7 +2817,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2686,7 +2825,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2694,7 +2833,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2702,7 +2841,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2710,7 +2849,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2718,7 +2857,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2726,7 +2865,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2734,7 +2873,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2742,7 +2881,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2750,7 +2889,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2758,7 +2897,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2766,7 +2905,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2774,7 +2913,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2782,7 +2921,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2790,7 +2929,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2798,7 +2937,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2806,7 +2945,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2814,7 +2953,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2822,7 +2961,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2830,7 +2969,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2838,7 +2977,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2846,7 +2985,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2854,7 +2993,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2862,7 +3001,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2870,7 +3009,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2878,7 +3017,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2886,7 +3025,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2894,7 +3033,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2902,7 +3041,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2910,7 +3049,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2918,7 +3057,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2926,7 +3065,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2934,7 +3073,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2942,7 +3081,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2950,7 +3089,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2958,7 +3097,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2966,7 +3105,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2974,7 +3113,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2982,7 +3121,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
||||
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
|
||||
},
|
||||
new
|
||||
@@ -2990,23 +3129,7 @@ namespace Distribution.Migrations
|
||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Id = new Guid("93c036e4-d811-4915-b73c-1cb358f17065"),
|
||||
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"),
|
||||
Id = new Guid("ec9db3b2-59b0-48f3-a6e0-0c81d6fbc854"),
|
||||
IsActive = true
|
||||
});
|
||||
});
|
||||
@@ -4984,6 +5107,69 @@ namespace Distribution.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Uom", "BaseUom")
|
||||
@@ -5101,6 +5287,25 @@ namespace Distribution.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
||||
@@ -5803,9 +6008,16 @@ namespace Distribution.Migrations
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.InvoiceVersion", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||
{
|
||||
b.Navigation("Conversions");
|
||||
@@ -5818,6 +6030,11 @@ namespace Distribution.Migrations
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||
{
|
||||
b.Navigation("Recipients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
|
||||
@@ -13,8 +13,5 @@ namespace Distribution.Models
|
||||
public string CurrencyCode { get; set; } = "LKR";
|
||||
|
||||
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 InvoiceStatus { Draft, Printed, Finalized }
|
||||
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
|
||||
|
||||
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 Guid? FinalizedBy { get; set; }
|
||||
|
||||
/// <summary>Scheme discount redeemed against the PO, subtracted from the billable lines to give TotalAmount.</summary>
|
||||
public decimal DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>Payable amount: billable lines minus DiscountAmount.</summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
|
||||
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
+11
-10
@@ -11,8 +11,8 @@ using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Features.DriverProfile;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Invoice;
|
||||
@@ -142,8 +142,15 @@ builder.Services.AddScoped<ChequeBookService>();
|
||||
builder.Services.AddScoped<ReceivedChequeService>();
|
||||
builder.Services.AddScoped<SupplierPaymentService>();
|
||||
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.AddHttpContextAccessor();
|
||||
@@ -153,13 +160,6 @@ builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(client =>
|
||||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.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<IScopeRepository, ScopeRepository>();
|
||||
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
||||
@@ -203,5 +203,6 @@ app.UseMiddleware<AuthMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHub<NotificationHub>(NotificationHub.Path);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"Urls": "http://localhost:8802",
|
||||
"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"
|
||||
},
|
||||
"GeneralLedger": {
|
||||
"BaseUrl": "http://localhost:6704/api/",
|
||||
"ApiKey": "ff303a4b1919092501a2ab2b8725709177bbb8f0025c58d39de4ad678e2b4eda"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -1,67 +1,330 @@
|
||||
# 21 — Notifications & Alerts
|
||||
|
||||
**Status**: Not Started
|
||||
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
|
||||
**Status**: v1 in-app built — backend, SignalR hub, frontend bell + view-all page, 10 unit tests. EF migration `AddNotifications` is generated but **not applied** to any database. Six of the seven Phase 1 triggers are wired into the services (see **Triggers**); `StockLow` is deferred. The temporary test endpoint and button were removed.
|
||||
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
|
||||
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
|
||||
produce.
|
||||
Real-time **in-app** notifications (bell + toast) triggered by events other modules already produce
|
||||
([mainPlan.md §15](./mainPlan.md#15-notifications--alerts)). Email / SMS / push are out of scope for v1.
|
||||
|
||||
Real-time uses **SignalR**. A notification is **saved first, pushed second**, so offline users see
|
||||
what they missed on their next load.
|
||||
|
||||
---
|
||||
|
||||
## Background / Context
|
||||
## Implemented now — who gets which notification
|
||||
|
||||
No notification channel exists in this codebase today. This step is purely the delivery mechanism
|
||||
and trigger wiring; the underlying conditions it watches for are already defined elsewhere
|
||||
(reorder point in [02_stock_ledger.md](./02_stock_ledger.md), credit limit in
|
||||
[12_store_management.md](./12_store_management.md), route deviation logic in
|
||||
[09_fraud_controls.md](./09_fraud_controls.md)).
|
||||
Every notification is **in-app only** (bell + toast + `/notifications`), pushed live over SignalR.
|
||||
|
||||
| # | When this happens | Where in the app | Notification the user sees | Who receives it | Who does **not** | Level | Click opens |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | Shop order **confirmed** | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` | **Shop order confirmed** — "Shop order for *Store* was confirmed." | The user who raised the order + that user's supervisor | The person who confirmed it | Info | `/sales/orders` |
|
||||
| 2 | Shop order **cancelled** | `ShopPOService.CancelAsync` | **Shop order cancelled** — "Shop order for *Store* was cancelled: *reason*" | The user who raised the order + that user's supervisor | The person who cancelled it | Warning | `/sales/orders` |
|
||||
| 3 | Supplier claim **approved** | `SupplierClaimService.ApproveAsync` | **Supplier claim approved** | The user who raised the claim | The approver | Success | `/returns-claims/supplier-claims` |
|
||||
| 4 | Supplier claim **rejected** | `SupplierClaimService.RejectAsync` | **Supplier claim rejected** | The user who raised the claim | The person who rejected it | Warning | `/returns-claims/supplier-claims` |
|
||||
| 5 | **Driver assigned** to a ride | `RideService.AssignDriverAsync` | **Ride assigned** — "You have been assigned to a ride." | The assigned driver | Everyone else | Info | `/operations/rides` |
|
||||
| 6 | Ride **cancelled** | `RideService.CancelAsync` | **Ride cancelled** — "A ride was cancelled (*type*). *reason*" | The owner of every route in the ride, plus users with `delivery-task.manage` whose scope covers those routes | The person who cancelled it | Critical | `/operations/rides` |
|
||||
| 7 | Delivery **failed** | `DeliveryTaskService.FailAsync` | **Delivery failed** — "Delivery to *Store* failed. *note*" | The owner of the store's route, plus users with `delivery-task.manage` whose scope covers the store | Nobody is excluded (this action does not record who did it) | Warning | `/operations/deliveries` |
|
||||
| 8 | Delivery **partly delivered** | `DeliveryTaskService.ConfirmAsync` | **Partial delivery** — "Delivery to *Store* was only partly delivered." | Same as #7 | The person who confirmed it | Warning | `/operations/deliveries` |
|
||||
| 9 | Store **credit limit crossed** | `StoreService.UpdateOutstandingBalanceAsync` | **Credit limit exceeded** — "*Store* has exceeded its credit limit." | The owner of the store's route, plus users with `store.manage` whose scope covers the store | The person who updated the balance. Fires only when the balance goes from within the limit to above it | Warning | `/stores/{id}` |
|
||||
|
||||
**Not wired yet:** low stock (`StockLow`), cheque returned / bounced / due, expiring batches, overdue payments, and the Phase 2 list below.
|
||||
|
||||
### Three rules that decide who sees what
|
||||
|
||||
1. **You are never notified of your own action.**
|
||||
2. **Receiving needs no permission** — any logged-in user gets the bell and the page. Permissions only matter for *doing* the action, and for the "managers in scope" recipients (they need the permission **and** a scope covering the store or route).
|
||||
3. **SuperAdmin usually acts, rarely receives.** It holds every permission but has no scope rows in the seed, so it is only a recipient when it created the record (orders, claims) or owns the route.
|
||||
|
||||
### With two logins — SuperAdmin and a sales rep
|
||||
|
||||
| Who does it | Action | Who gets the notification |
|
||||
|---|---|---|
|
||||
| Rep creates, **SuperAdmin** confirms or cancels | Shop order | **Rep** (creator), plus the rep's supervisor if they have one |
|
||||
| **SuperAdmin** creates, rep confirms or cancels | Shop order | **SuperAdmin** (creator) |
|
||||
| SuperAdmin | Sets a store's outstanding balance above its limit | The **rep**, if the rep owns that store's route. SuperAdmin never (actor) |
|
||||
| SuperAdmin | Assigns a driver to a ride | The **rep**, only if the rep is that driver |
|
||||
| SuperAdmin | Approves or rejects a supplier claim | Whoever raised the claim — the **rep** only if the rep raised it |
|
||||
| Anyone | Fails a delivery / cancels a ride | The **rep**, if the rep owns a route involved. SuperAdmin only if it has a scope on that route |
|
||||
|
||||
Seed route owners: `nimal.perera` owns the Colombo and Negombo routes; `kamal.silva` owns Kandy and Matale.
|
||||
|
||||
### Quick test (two browsers, one login each, both on a dashboard page)
|
||||
|
||||
1. As the **rep**, create a shop order (Sales → New).
|
||||
2. As **SuperAdmin**, confirm it, then create a second one and cancel it with a reason.
|
||||
3. The rep sees a toast, a red badge on the bell and a new row on `/notifications` — "Shop order confirmed" and "Shop order cancelled: *reason*". Clicking one marks it read and opens `/sales/orders`.
|
||||
4. Reverse it: SuperAdmin creates an order and the rep confirms or cancels it — SuperAdmin is notified.
|
||||
5. Credit limit: there is no screen for it, so use Swagger (`/swagger`): `PUT /api/stores/{id}/outstanding-balance` with `{ "newBalance": 60000 }` on a store whose limit is lower (Fort Mini Mart: limit 50,000). A second call with a higher value does not alert again; drop the balance back under the limit and set it over again to re-trigger.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
## Flow
|
||||
|
||||
### Notification
|
||||
|
||||
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
|
||||
`ReadAt` (nullable).
|
||||
```
|
||||
Service action ──► uow.CommitAsync() (business transaction, unchanged)
|
||||
│
|
||||
▼ only after a successful commit
|
||||
NotificationService.NotifyAsync(type, recipientRule, args, entity)
|
||||
1. resolve recipients (users | permission | scope | reports-to)
|
||||
2. insert Notification + NotificationRecipient rows
|
||||
3. hub.Clients.Users(ids).SendAsync("notification", dto)
|
||||
▼
|
||||
Frontend provider: connection.on("notification") → badge + toast → click opens Link + marks read
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
## Decisions
|
||||
|
||||
| Function reference | Endpoint intent |
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) |
|
||||
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
|
||||
| `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) |
|
||||
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. |
|
||||
| — | `GET /api/notifications` — recipient's own notification inbox |
|
||||
| — | `PUT /api/notifications/{id}/read` |
|
||||
| First batch of triggers | The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed |
|
||||
| Channels | In-app only (SignalR). Email / SMS / push deferred |
|
||||
| Per-type config table + admin screen | **Not in v1.** Every type is always enabled; add `NotificationModuleConfig` when someone needs to mute a type |
|
||||
| Scheduled alerts (Phase 1b) | Second pass. Only the retention cleanup job ships in v1 |
|
||||
| CORS / instances | Existing `Cors:AllowedOrigins` + `AllowCredentials()` already suit SignalR. Single API instance, no Redis backplane |
|
||||
| Recipients | By **permission code** and scope, not role name. The seeded roles are `SalesRep`, `Supervisor`, `AreaManager`, `AreaSupervisor`, `RegionalManager`, `RegionalAgency`, `SuperAdmin`; there is no Accounts / operations role |
|
||||
| Ids | `User`, `Permission` and entity ids are **Guid** (see seeds) |
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
## Data Model
|
||||
|
||||
- Every `send*` function in §15 is a trigger, not a user-facing endpoint — the only client-facing
|
||||
endpoints are inbox read/list.
|
||||
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
|
||||
transaction that triggered them — fire-and-forget, logged separately.
|
||||
**Notification** — one row per event
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Id` | Guid PK | |
|
||||
| `Type` | varchar(60) | `NotificationTypes` constant; frontend maps it to an icon |
|
||||
| `Severity` | enum | `Info`, `Success`, `Warning`, `Critical` |
|
||||
| `Title` / `Message` | varchar(150) / varchar(500) | Built from the type's templates |
|
||||
| `EntityType` / `EntityId` | varchar(50) / Guid | The related record |
|
||||
| `Link` | varchar(200) | Frontend route, e.g. `/payments/received-cheques/88` |
|
||||
| `CreatedByUserId` | FK User, null | Null for system / scheduled events |
|
||||
| `CreatedAt` | datetime | `DateTime.Now` |
|
||||
| `DedupeKey` | — | **Not in v1.** Added with the Phase 1b scheduled jobs, e.g. `ItemBatchExpiring:77:2026-10-20` |
|
||||
|
||||
**NotificationRecipient** — one row per user per event (read state is per user)
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `Id` | Guid PK |
|
||||
| `NotificationId` | FK |
|
||||
| `UserId` | FK |
|
||||
| `IsRead` / `ReadAt` | bool / datetime null |
|
||||
|
||||
Index `(UserId, IsRead)`; the bell orders by the joined `Notification.CreatedAt DESC`.
|
||||
|
||||
No config table in v1 — every type is enabled. (Later: `NotificationModuleConfig` with `Type` PK +
|
||||
`InAppEnabled`, missing row = enabled.)
|
||||
|
||||
Example (failed delivery):
|
||||
```
|
||||
Notification: Type=DeliveryTaskFailed, Severity=Warning,
|
||||
Title="Delivery failed", Message="Delivery to Fort Mini Mart failed.",
|
||||
EntityType=DeliveryTask, EntityId=<guid>, Link=/operations/delivery-tasks/<guid>
|
||||
NotificationRecipient: (that notification, Nimal Perera - route owner, unread)
|
||||
(that notification, a delivery-task.manage user in Western region, unread)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipients
|
||||
|
||||
Every trigger names its rule. There is **no "notify everyone"** option.
|
||||
|
||||
| Rule | Resolves to |
|
||||
|---|---|
|
||||
| `Users(ids)` | Those users (creator, driver, approver, claim owner) |
|
||||
| `Permission(code)` | Active users whose role has that permission code (`User.FK_RoleId` → `RolePermission` → `Permission.Code`), e.g. `delivery-task.manage` |
|
||||
| `Scope(kind, id)` | Users whose `UserStoreScope` / `UserRegionScope` / `UserRouteScope` covers the record |
|
||||
| `PermissionInScope(code, kind, id)` | Intersection of the two (e.g. `store.manage` users for that store's region) |
|
||||
| `ReportsTo(userId)` | The user's supervisor via `User.FK_ReportsToUserId` |
|
||||
| `RouteOwner(kind, id)` | The owner (`Route.FK_OwnerUserId`) of the route, or of the store's route when `kind` is `Store` |
|
||||
|
||||
`SuperAdmin` holds every permission, so it receives `Permission` rules automatically; use
|
||||
`PermissionInScope` where that would be too noisy.
|
||||
|
||||
The resolver always drops inactive users and the acting user, and de-duplicates.
|
||||
|
||||
---
|
||||
|
||||
## Triggers
|
||||
|
||||
Each is called **after** the owning service's `uow.CommitAsync()`.
|
||||
|
||||
**Phase 1 — must have**
|
||||
|
||||
| Type | Raised by | When | Recipients | Severity |
|
||||
|---|---|---|---|---|
|
||||
| Type | Status | Raised by | When | Recipients | Severity |
|
||||
|---|---|---|---|---|---|
|
||||
| `SupplierClaimApproved` / `Rejected` | ✅ wired | `SupplierClaimService.ApproveAsync` / `RejectAsync` | Decision | `Users(RaisedByUserId)` (not the decider) | Success / Warning |
|
||||
| `RideCancelled` | ✅ wired | `RideService.CancelAsync` | Cancelled (message carries `CancelRequestType` + reason) | For each distinct route in the ride's delivery tasks: `RouteOwner(Route)` + `PermissionInScope(delivery-task.manage, Route)`. A ride has no route of its own, so routes come from its tasks' stores | Critical |
|
||||
| `RideDriverAssigned` | ✅ wired | `RideService.AssignDriverAsync` | Driver assigned | `Users(driver.FK_UserId)` | Info |
|
||||
| `DeliveryTaskFailed` | ✅ wired | `DeliveryTaskService.FailAsync` | Task failed | `RouteOwner(Store)` + `PermissionInScope(delivery-task.manage, Store)` | Warning |
|
||||
| `DeliveryTaskPartiallyDelivered` | ✅ wired | `DeliveryTaskService.ConfirmAsync` | Confirmed with `fullyDelivered == false` (a separate type: "failed" would be the wrong wording) | same as above | Warning |
|
||||
| `ShopPOConfirmed` / `Cancelled` | ✅ wired | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` / `CancelAsync` | Status change | `Users(RaisedById)` + `ReportsTo(RaisedById)` (not the actor) | Info / Warning |
|
||||
| `CreditLimitBreached` | ✅ wired | `StoreService.UpdateOutstandingBalanceAsync` | Balance crosses from ≤ to > `CreditLimit` (crossing only). This is the only place `OutstandingBalance` is ever written — invoices and payments do not update it | `RouteOwner(Store)` + `PermissionInScope(store.manage, Store)` | Warning |
|
||||
| `StockLow` | ⏸ deferred | Stock movement | Crosses to ≤ `Item.ReorderLevel` | Undecided — warehouses have no region / scope link, so `PermissionInScope` cannot target them. Needs a per-request tracker + action filter because stock moves from many call sites that commit separately | Warning |
|
||||
|
||||
**Waiting on finance permissions** (not in the seed yet — add a cheque permission first)
|
||||
|
||||
`ReceivedChequeReturned` (`ReceivedChequeService.UpdateStatusAsync`, action `Return`, Critical) ·
|
||||
`ChequePageBounced` (`ChequeBookService.UpdatePageStatusAsync`, action `Bounce`, Critical)
|
||||
|
||||
**Phase 1b — scheduled, second pass** (daily `BackgroundService`, uses `DedupeKey`)
|
||||
|
||||
`ReceivedChequeDueSoon` (≤ 2 days) · `ItemBatchExpiring` (≤ N days) · `PaymentOverdue` ·
|
||||
`DriverComplianceExpiring`
|
||||
|
||||
**Phase 2 — later**
|
||||
|
||||
`PoCancelled` / `PoClosed` · `GrnShortOrDamaged` · `LoadingSheetConfirmed` · `InvoiceFinalized` ·
|
||||
`ReturnCreated` / `ClaimSettled` · `PurchaseReturnCreated` · `ApprovalPending` ·
|
||||
`RouteDeviationFlagged` · `RoleAssigned` / `ScopeChanged` · `SyncFailed`
|
||||
|
||||
Excluded on purpose: plain master-data CRUD (brand, category, UOM, vehicle, region).
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/notifications?unreadOnly=&page=&pageSize=` | Caller's inbox, newest first |
|
||||
| `GET /api/notifications/unread-count` | Badge count |
|
||||
| `PATCH /api/notifications/{id}/read` | Mark one read (own row only) |
|
||||
| `PATCH /api/notifications/read-all` | Mark all read |
|
||||
| `DELETE /api/notifications/{id}` | Remove caller's recipient row |
|
||||
| `WS /hubs/notifications` | SignalR; server → client event `notification` |
|
||||
|
||||
---
|
||||
|
||||
## Backend structure
|
||||
|
||||
```
|
||||
Features/Notifications/ (namespace Distribution.Features.Notifications — "Notification" alone clashes with Models.Notification)
|
||||
NotificationController.cs thin, no UoW
|
||||
NotificationService.cs resolve → persist → push; owns uow.CommitAsync()
|
||||
INotificationRepository.cs / NotificationRepository.cs
|
||||
NotificationRecipientResolver.cs Users / Permission / Scope / PermissionInScope / ReportsTo
|
||||
NotificationTypes.cs constants + definition (severity, title/message template)
|
||||
NotificationDTOs.cs
|
||||
NotificationHub.cs SignalR hub
|
||||
NotificationCleanupJob.cs daily retention purge
|
||||
NotificationScheduleJob.cs Phase 1b alerts (second pass)
|
||||
Models/Notification.cs, NotificationRecipient.cs
|
||||
```
|
||||
|
||||
### Code reference
|
||||
|
||||
**Registry** — services never pass string literals:
|
||||
```csharp
|
||||
public static class NotificationTypes
|
||||
{
|
||||
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
|
||||
// ...
|
||||
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All = new Dictionary<string, NotificationTypeDefinition>
|
||||
{
|
||||
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
|
||||
"Delivery failed", "Delivery to {StoreName} failed."),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Service contract** — never throws; failures are logged:
|
||||
```csharp
|
||||
public interface INotificationService
|
||||
{
|
||||
// recipients = union of the rules, then resolved, filtered (inactive, actor) and de-duplicated
|
||||
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
||||
}
|
||||
// RecipientRule: Users(ids) | Permission(code) | Scope(kind, id) | PermissionInScope(code, kind, id) | ReportsTo(userId) | RouteOwner(kind, id)
|
||||
// NotificationArgs: Values (template tokens), EntityType, EntityId (Guid), Link, CreatedByUserId (Guid?)
|
||||
```
|
||||
|
||||
**Calling it from an existing service:**
|
||||
```csharp
|
||||
await _uow.CommitAsync(ct);
|
||||
await _notifications.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||
[RecipientRule.Users(route.FK_OwnerUserId),
|
||||
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Region, route.FK_RegionId)],
|
||||
new(new { StoreName = store.StoreName }, "DeliveryTask", task.Id,
|
||||
$"/operations/delivery-tasks/{task.Id}", currentUserId), ct);
|
||||
```
|
||||
|
||||
**Program.cs:**
|
||||
```csharp
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddHostedService<NotificationCleanupJob>();
|
||||
// CORS: AllowCredentials() with explicit origins
|
||||
app.MapHub<NotificationHub>("/hubs/notifications");
|
||||
```
|
||||
The hub authenticates through the existing `access-token` cookie (read by `AuthMiddleware`); the
|
||||
client connects with `withCredentials: true`. A `?access_token=` query fallback is not needed unless
|
||||
the token ever moves out of the cookie. Users are addressed by their `UserId` claim
|
||||
(`NotificationUserIdProvider`). Each recipient gets its own push because the DTO `Id` is the
|
||||
recipient row id.
|
||||
|
||||
**Frontend** (`contexts/notification-context.tsx`):
|
||||
```tsx
|
||||
const connection = new HubConnectionBuilder()
|
||||
.withUrl(`${API}/hubs/notifications`, { withCredentials: true })
|
||||
.withAutomaticReconnect().build();
|
||||
|
||||
connection.on("notification", (n: NotificationDto) => {
|
||||
setUnread((c) => c + 1);
|
||||
setItems((p) => [n, ...p]);
|
||||
toast(n.title, { description: n.message, action: { label: "Open", onClick: () => router.push(n.link) } });
|
||||
});
|
||||
connection.onreconnected(refetch); // catch up on anything missed
|
||||
```
|
||||
Also: `lib/api/notifications.ts`, bell + popover in the dashboard header, `/notifications` "view all" page.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- Notify **after** a successful commit, inside try/catch + log. A notification failure must never
|
||||
fail or roll back the business action.
|
||||
- Saving and the hub push are separate try/catch blocks (a failed push still leaves the inbox entry).
|
||||
- Unknown type: throws in dev/test, logged and skipped in production. A unit test checks that every
|
||||
constant has a definition.
|
||||
- No per-type config in v1: every type is always delivered.
|
||||
- Click-through is mandatory: `EntityType`, `EntityId` and `Link` are required; clicking marks read.
|
||||
- Retention: `NotificationCleanupJob` deletes read recipients older than 90 days (SysConfig), then
|
||||
events with no recipients left.
|
||||
- No Email/SMS switches in the UI until a sender exists.
|
||||
- `AGENTS.md`: `uow.CommitAsync()` only in services, never `SaveChangesAsync()`, `DateTime.Now` only.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
|
||||
significantly changes the infrastructure needed (third-party provider integration, see
|
||||
[22_integration_api.md](./22_integration_api.md)).
|
||||
Resolved — see **Decisions**. Remaining for later:
|
||||
|
||||
1. Add a cheque / finance permission to the seed, then enable the two cheque triggers.
|
||||
2. Email / SMS / push: see [22_integration_api.md](./22_integration_api.md).
|
||||
3. If the API ever runs on more than one instance, add a SignalR Redis backplane.
|
||||
4. Per-type mute (`NotificationModuleConfig` + admin screen) when requested.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
## Build order
|
||||
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
1. Entities (`Notification`, `NotificationRecipient`) + EF migration.
|
||||
2. `Features/Notifications/` (repository, resolver, service, controller, DTOs, `NotificationTypes`) + retention job.
|
||||
3. SignalR hub, `Program.cs` registration, user-id provider.
|
||||
4. Frontend: API client, provider, bell, toast, click-through, view-all page.
|
||||
|
||||
5. Phase 1 triggers — six wired (recipient resolution is unit-tested; the per-service calls are not,
|
||||
because `Distribution.Tests` does not currently compile). `StockLow` deferred.
|
||||
6. Later: `StockLow`, cheque triggers, Phase 1b scheduled alerts, Phase 2 triggers.
|
||||
|
||||
Remaining before this works end to end: apply the `AddNotifications` migration.
|
||||
|
||||
@@ -82,6 +82,89 @@ Invoice
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### Invoice Line Items & Version History
|
||||
|
||||
> **Implemented.** Class/column names follow the code: the PO is `ShopPO`/`ShopPOItem`, and the
|
||||
> invoice FK is `FK_ShopPOId` (not `FK_OrderId` as sketched above).
|
||||
|
||||
The invoice no longer bills straight from the PO. It owns its own lines, and every change to them is
|
||||
snapshotted, so the original, printed and finalized quantities can all be compared.
|
||||
|
||||
```
|
||||
InvoiceItem — the live billing lines
|
||||
├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
|
||||
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
|
||||
├── UnitPrice (decimal)
|
||||
└── 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.
|
||||
- Not included (no data in the app): company/"From" block, logo, bank details, discount, tax, due date.
|
||||
|
||||
**Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a
|
||||
paper-size selector (A4 / 80mm / 58mm), sets `@page` with `margin: 0` (so the browser's own
|
||||
URL/date header and footer are not printed; the A4 sheet pads itself with 14mm), and opens the
|
||||
print dialog automatically. *Print* on a Draft asks who printed it, calls `POST /print`, then opens
|
||||
this tab (the tab is opened inside the click so popup blockers allow it). Printed/Finalized
|
||||
invoices have *Reprint*, which opens the tab without changing status.
|
||||
|
||||
**Draft editor** — pencil icon (Draft only, also *Edit Items* on the invoice page) opens a full page, `/sales/invoices/{id}/edit` (not a modal), showing the invoice template in edit mode: quantity/price
|
||||
inputs, a trash icon per line (undo icon restores a deleted line), and an add-item row aligned to
|
||||
the table columns (item dropdown excluding items already on the invoice, quantity, price
|
||||
auto-filled from the store's price list, Enter or **+** to add). Save is disabled until something
|
||||
changes, a summary shows what is pending (the browser warns before leaving with unsaved edits), and an item picked but not yet added is included on save. Non-Draft invoices redirect back to the invoice page.
|
||||
Each save writes a `DraftEdited` version.
|
||||
|
||||
**Configuration** — *Invoice Creation* card: two toggles, **Auto** (Mode A) and **Manual** (Mode B,
|
||||
default), mutually exclusive (turning one on turns the other off; the active one is locked on), each
|
||||
with a one-line explanation and a process line (`Confirm PO → … → Edit draft → Print → Finalize`).
|
||||
|
||||
### New Enum — `InvoiceStatus`
|
||||
|
||||
```csharp
|
||||
@@ -140,6 +223,7 @@ Draft → Printed → Finalized
|
||||
|---|---|---|
|
||||
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. `Order.InvoiceId` set. |
|
||||
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by `agentId` / `routeId` / `shopId`) and creates the Invoice at `Draft`. `Order.InvoiceId` set. |
|
||||
| Draft (edit) | `PUT /api/invoices/{id}/items` | Office user edits the draft's lines (quantity `0..ordered`, price `>= 0`). Status stays `Draft`; `TotalAmount` recomputed; a `DraftEdited` version is saved. |
|
||||
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
|
||||
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
|
||||
|
||||
@@ -162,7 +246,10 @@ Draft → Printed → Finalized
|
||||
Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
|
||||
7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
|
||||
8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
|
||||
reflects the current line items.
|
||||
reflects the current invoice lines (`InvoiceItem`), not the PO lines.
|
||||
8a. Invoice lines are editable only while `Draft`. At `Finalize`, only quantities can be adjusted
|
||||
(never above the ordered quantity). Every change writes an `InvoiceVersion` snapshot — see
|
||||
[Invoice Line Items & Version History](#invoice-line-items--version-history).
|
||||
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
|
||||
separate axis from `Invoice.Status` — not merged in this pass.
|
||||
10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
|
||||
@@ -201,8 +288,9 @@ Draft → Printed → Finalized
|
||||
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
||||
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
||||
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
|
||||
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
|
||||
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
||||
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
|
||||
Returns, damage and discounts are still open, as is the payload shape for them. "Free issues" handling (items
|
||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
||||
|
||||
---
|
||||
|
||||
+21
-3
@@ -35,10 +35,28 @@ status transition and stock movement along the way.
|
||||
|
||||
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
|
||||
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
|
||||
(No frontend UI currently calls this endpoint — only the plain `Confirm` action is wired up.)
|
||||
The frontend calls it when **Configuration → Invoice Creation** is set to **Auto** (Mode A, the
|
||||
confirm dialog and PO edit page read the mode); with **Manual** (Mode B, the default) it calls the
|
||||
plain `Confirm` and the invoice is created later from `/sales/orders` → **Create Invoice**.
|
||||
- Lifecycle: `Draft → Printed → Finalized`.
|
||||
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
|
||||
quantity) and recomputes `TotalAmount` from the adjusted lines.
|
||||
- The invoice has its own lines (`InvoiceItem`), copied from the PO's active lines when it is
|
||||
created (either mode). The PO's original quantity stays on `ShopPOItem` and is shown as
|
||||
`orderedQuantity`.
|
||||
- While `Draft`, lines can be edited (`PUT /invoices/{id}/items`): change quantity (`0..ordered`
|
||||
on PO lines) and price (`>= 0`), **delete** a line (it is kept, struck through, excluded from
|
||||
the total, and can be restored), or **add** a catalog item that was not on the PO (no ordered
|
||||
quantity / max). `TotalAmount` is recomputed from the non-deleted lines.
|
||||
- Finalizing applies the agent's edits from the printed copy to the lines (quantity capped
|
||||
between `0` and the ordered quantity, or a line removed) and recomputes `TotalAmount`.
|
||||
- Every change is snapshotted as an `InvoiceVersion` — see
|
||||
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-line-items--version-history).
|
||||
- Frontend, `/sales/orders` row icons: **eye** = invoice preview, **pencil** (Draft) = open the edit page for the
|
||||
draft invoice (`/sales/invoices/[id]/edit`: trash/undo icon per line, add-item row), **printer** (Draft) =
|
||||
mark as printed and open the print tab; on Printed/Finalized the printer reprints without
|
||||
changing status; plus finalize and record payment. `/sales/invoices/[id]` shows the items table
|
||||
(ordered vs billed, Added/Removed badges), the version history, and the same actions.
|
||||
- Invoice document and print tab: see
|
||||
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-screens--printing-frontend).
|
||||
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
|
||||
`Pending → PartiallyPaid → Paid`.
|
||||
|
||||
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
|
||||
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||
import { RegionSalesPie } from './region-sales-pie'
|
||||
import { SalesTrendInteractive } from './sales-trend-interactive'
|
||||
|
||||
export interface DateRange {
|
||||
from: string // yyyy-MM-dd
|
||||
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
||||
function LoadingGrid() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-busy="true">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||
|
||||
const { current: c, previous: p } = sales.kpis
|
||||
const { current: cash, previous: cashPrev } = sales.cash
|
||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||
<Kpi
|
||||
title={title}
|
||||
@@ -184,12 +187,11 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
)
|
||||
|
||||
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
||||
const regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
|
||||
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
{kpi('Net Sales', 'netSales')}
|
||||
{kpi('Gross Sales', 'grossSales')}
|
||||
{kpi('Total Orders', 'orders', false)}
|
||||
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
}
|
||||
/>
|
||||
{kpi('Sales Returns', 'returnValue')}
|
||||
{kpi('Discounts', 'discounts')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Kpi
|
||||
title="Collected"
|
||||
value={fmtMoney(cash.collected)}
|
||||
footer={<Delta current={cash.collected} previous={cashPrev.collected} />}
|
||||
/>
|
||||
<Kpi
|
||||
title="Outstanding"
|
||||
value={fmtMoney(cash.outstanding)}
|
||||
footer={<span className="text-muted-foreground">Unpaid on finalized invoices</span>}
|
||||
/>
|
||||
<Kpi
|
||||
title="Overdue"
|
||||
value={fmtMoney(cash.overdue)}
|
||||
footer={<span className="text-muted-foreground">Unpaid, invoiced over 30 days ago</span>}
|
||||
/>
|
||||
<Kpi
|
||||
title="Collection Rate"
|
||||
value={cash.collectionRatePercent === null ? '—' : `${cash.collectionRatePercent}%`}
|
||||
footer={<span className="text-muted-foreground">Collected ÷ billed (gross − discounts)</span>}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns.
|
||||
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
||||
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
|
||||
Net = gross − customer returns that deduct credit (item exchanges are net zero) − scheme discounts on those invoices
|
||||
(not split by category, so Discounts is 0 when a category is selected). Collected = cash and cheque payments received;
|
||||
credit-method payments stay outstanding. Outstanding and overdue are balances as of the end of the range, and Collected ignores the category filter.
|
||||
Gross profit uses item cost prices and covers only items that have one. Sales targets are not tracked per order yet.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<ChartCard title="Sales Trend" note={`Gross sales per ${sales.granularity}`} className="md:col-span-2">
|
||||
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
||||
</ChartCard>
|
||||
<SalesTrendInteractive className="md:col-span-2" />
|
||||
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||
</ChartCard>
|
||||
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
])}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Sales by Region">
|
||||
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
||||
</ChartCard>
|
||||
<RegionSalesPie rows={sales.byRegion} />
|
||||
|
||||
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||
<MultiAreaChart
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
||||
import type { StockMovementResponse } from '@/types/api'
|
||||
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
||||
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
||||
@@ -54,7 +54,6 @@ const WIDGET_META: WidgetMeta[] = [
|
||||
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
||||
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
||||
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
||||
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
|
||||
|
||||
const DEFAULT_ACTIVE = [
|
||||
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
||||
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
||||
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
|
||||
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
||||
]
|
||||
|
||||
@@ -153,7 +152,6 @@ interface DashboardData {
|
||||
confirmedSheetsCount: number
|
||||
pendingTasksCount: number
|
||||
partialTasksCount: number
|
||||
lowStock: LowStockItemResponse[]
|
||||
movements: StockMovementResponse[]
|
||||
warehouseBalances: WarehouseBalanceRow[]
|
||||
outstandingAmount: number
|
||||
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
|
||||
openRides: [],
|
||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||
pendingTasksCount: 0, partialTasksCount: 0,
|
||||
lowStock: [], movements: [], warehouseBalances: [],
|
||||
movements: [], warehouseBalances: [],
|
||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
||||
@@ -236,14 +234,6 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('lowStock')) {
|
||||
fetches.push(
|
||||
stock.lowStock()
|
||||
.then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) })
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('stockMovements')) {
|
||||
fetches.push(
|
||||
stock.movements({ pageSize: 5 })
|
||||
@@ -400,24 +390,6 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||
</div>
|
||||
)
|
||||
case 'lowStock':
|
||||
return data.lowStock.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
|
||||
</div>
|
||||
{data.lowStock.map(row => (
|
||||
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
|
||||
<span className="text-foreground">{row.itemName}</span>
|
||||
<span className="text-muted-foreground">{row.locationName}</span>
|
||||
<span className="font-semibold text-destructive">{row.quantity}</span>
|
||||
<span className="text-muted-foreground">{row.reorderLevel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'stockMovements':
|
||||
return data.movements.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Label, Pie, PieChart, Sector } from 'recharts'
|
||||
import type { PieSectorShapeProps } from 'recharts/types/polar/Pie'
|
||||
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
|
||||
const MAX_SLICES = 5
|
||||
|
||||
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||
|
||||
/** Top (n-1) regions plus an "Other" slice, so the pie and the picker stay readable. */
|
||||
function topWithOther(rows: Array<{ name: string; amount: number }>) {
|
||||
const sorted = [...rows].filter(r => r.amount > 0).sort((a, b) => b.amount - a.amount)
|
||||
if (sorted.length <= MAX_SLICES) return sorted
|
||||
const head = sorted.slice(0, MAX_SLICES - 1)
|
||||
return [...head, { name: 'Other', amount: sorted.slice(MAX_SLICES - 1).reduce((t, r) => t + r.amount, 0) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive pie of gross sales by region: pick a region (or it defaults to the largest) to pull its slice
|
||||
* out and show its amount and share in the centre.
|
||||
*/
|
||||
export function RegionSalesPie({ rows, className }: { rows: Array<{ name: string; amount: number }>; className?: string }) {
|
||||
const slices = useMemo(
|
||||
() => topWithOther(rows).map((r, i) => ({ key: `r${i}`, name: r.name, amount: r.amount, fill: `var(--color-r${i})` })),
|
||||
[rows],
|
||||
)
|
||||
const config = useMemo(
|
||||
() => Object.fromEntries(slices.map((s, i) => [s.key, { label: s.name, color: SLICE_COLORS[i % SLICE_COLORS.length] }])) as ChartConfig,
|
||||
[slices],
|
||||
)
|
||||
const total = slices.reduce((t, s) => t + s.amount, 0)
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
// Fall back to the largest region when nothing is picked or the picked region left the data (range changed).
|
||||
const activeKey = slices.some(s => s.key === selected) ? selected : slices[0]?.key
|
||||
const activeIndex = slices.findIndex(s => s.key === activeKey)
|
||||
const active = slices[activeIndex]
|
||||
|
||||
const renderShape = useCallback(
|
||||
({ index, outerRadius = 0, ...props }: PieSectorShapeProps) =>
|
||||
index === activeIndex ? (
|
||||
<g>
|
||||
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||
<Sector {...props} outerRadius={outerRadius + 25} innerRadius={outerRadius + 12} />
|
||||
</g>
|
||||
) : (
|
||||
<Sector {...props} outerRadius={outerRadius} />
|
||||
),
|
||||
[activeIndex],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales by Region</CardTitle>
|
||||
<CardDescription>Share of gross sales</CardDescription>
|
||||
{active && (
|
||||
<CardAction>
|
||||
<Select value={activeKey} onValueChange={v => v && setSelected(v)}>
|
||||
<SelectTrigger size="sm" className="w-36" aria-label="Select a region">
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{slices.map((s, i) => (
|
||||
<SelectItem key={s.key} value={s.key} label={s.name}>
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<span className="size-3 shrink-0 rounded-xs" style={{ backgroundColor: SLICE_COLORS[i % SLICE_COLORS.length] }} />
|
||||
{s.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 justify-center">
|
||||
{!active ? (
|
||||
<p className="flex h-52 items-center justify-center text-xs text-muted-foreground">No sales in this period.</p>
|
||||
) : (
|
||||
<ChartContainer config={config} className="mx-auto aspect-square w-full max-w-[260px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel nameKey="key" formatter={(value, _name, item) => (
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{(item.payload as { name: string }).name}</span>
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">{fmtMoney(Number(value))}</span>
|
||||
</div>
|
||||
)} />}
|
||||
/>
|
||||
<Pie data={slices} dataKey="amount" nameKey="key" innerRadius={60} strokeWidth={5} shape={renderShape}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||
const cy = viewBox.cy ?? 0
|
||||
return (
|
||||
<text x={viewBox.cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={cy - 6} className="fill-foreground text-xl font-bold">{fmtCompact(active.amount)}</tspan>
|
||||
<tspan x={viewBox.cx} y={cy + 12} className="fill-muted-foreground text-[11px]">
|
||||
{total > 0 ? `${((active.amount / total) * 100).toFixed(1)}%` : ''}
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { dashboardReports, getApiError } from '@/lib/api/index'
|
||||
import type { DashboardSalesSummary } from '@/lib/api/reports'
|
||||
|
||||
const MAX_DAYS = 90
|
||||
|
||||
const RANGES = [
|
||||
{ value: '90', label: 'Last 3 months' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
sales: { label: 'Gross sales', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
|
||||
const parseIso = (v: string) => new Date(`${v}T00:00:00`)
|
||||
|
||||
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||
const fmtTick = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
const fmtLabel = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
interface Loaded {
|
||||
end: string // yyyy-MM-dd of the last day covered
|
||||
trend: DashboardSalesSummary['trend']
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive area chart of daily gross sales. The last 90 days are loaded once and the
|
||||
* time-range selector narrows them client-side. Days without activity are plotted as 0 so every day shows.
|
||||
*/
|
||||
export function SalesTrendInteractive({ className }: { className?: string }) {
|
||||
const [range, setRange] = useState('90')
|
||||
const [loaded, setLoaded] = useState<Loaded | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const today = new Date()
|
||||
dashboardReports
|
||||
.sales({ from: toIso(addDays(today, -(MAX_DAYS - 1))), to: toIso(today) })
|
||||
.then(res => { if (!cancelled) setLoaded({ end: toIso(today), trend: res.trend }) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
toast.add({ title: 'Failed to load sales trend', description: getApiError(err), type: 'error' })
|
||||
setLoaded(null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const days = Number(range)
|
||||
const data = useMemo(() => {
|
||||
if (!loaded) return []
|
||||
const byDay = new Map(loaded.trend.map(t => [t.period, t]))
|
||||
const end = parseIso(loaded.end)
|
||||
return Array.from({ length: days }, (_, i) => {
|
||||
const date = toIso(addDays(end, i - (days - 1)))
|
||||
const point = byDay.get(date)
|
||||
return { date, sales: point?.sales ?? 0 }
|
||||
})
|
||||
}, [loaded, days])
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.sales, 0)
|
||||
const rangeLabel = RANGES.find(r => r.value === range)?.label.toLowerCase() ?? ''
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Trend</CardTitle>
|
||||
<CardDescription>
|
||||
{loaded ? `${fmtMoney(total)} gross sales in the ${rangeLabel}` : 'Gross sales per day'}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Select value={range} onValueChange={v => v && setRange(v)}>
|
||||
<SelectTrigger size="sm" className="w-40" aria-label="Select time range">
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loaded === undefined ? (
|
||||
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||
) : loaded === null || data.every(d => d.sales === 0) ? (
|
||||
<p className="flex h-[250px] items-center justify-center text-xs text-muted-foreground">
|
||||
{loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`}
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="fillSales" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} tickFormatter={fmtTick} />
|
||||
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" labelFormatter={v => fmtLabel(String(v))} />}
|
||||
/>
|
||||
<Area dataKey="sales" type="natural" fill="url(#fillSales)" stroke="var(--color-sales)" />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
+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 { GrnTaxCard } from './_components/grn-tax-card'
|
||||
import { InvoiceCreationModeCard } from './_components/invoice-creation-mode-card'
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
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>
|
||||
</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="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||
<ProductCapabilitiesCard />
|
||||
@@ -19,6 +19,9 @@ export default function ConfigurationPage() {
|
||||
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||
<GrnTaxCard />
|
||||
</div>
|
||||
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||
<InvoiceCreationModeCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
|
||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||
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'
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -10,21 +12,24 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<TopLoader />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||
<div className="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
<BottomNav />
|
||||
</SidebarProvider>
|
||||
<NotificationProvider>
|
||||
<SidebarProvider>
|
||||
<TopLoader />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<NotificationBell />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||
{children}
|
||||
</main>
|
||||
</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 />
|
||||
}
|
||||
-205
@@ -1,205 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableFooter, 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 { financeReports, type FileFormat, type GlAccountOption, type FinanceReportParams, type FinanceReportType } from '@/lib/api/finance-reports'
|
||||
|
||||
import { toBlocks, type Block } from './report-blocks'
|
||||
|
||||
type Filter = 'asOf' | 'range' | 'account' | 'budget'
|
||||
|
||||
const REPORTS: { type: FinanceReportType; label: string; filters: Filter[]; hint: string }[] = [
|
||||
{ type: 'TrialBalance', label: 'Trial Balance', filters: ['asOf'], hint: 'Debit and credit balance of every ledger account as of a date.' },
|
||||
{ type: 'GeneralLedger', label: 'General Ledger', filters: ['range', 'account'], hint: 'Every posted transaction line with the account debited or credited, grouped by account.' },
|
||||
{ type: 'ProfitAndLoss', label: 'Profit & Loss', filters: ['range'], hint: 'Income and expenses for a period.' },
|
||||
{ type: 'BalanceSheet', label: 'Balance Sheet', filters: ['asOf'], hint: 'Assets, liabilities and equity as of a date.' },
|
||||
{ type: 'CashFlow', label: 'Cash Flow', filters: ['range'], hint: 'Cash movement for a period (indirect method).' },
|
||||
{ type: 'BudgetVsActual', label: 'Budget vs Actual', filters: ['budget'], hint: 'Budget lines against posted actuals. Budgets are created in the General Ledger service.' },
|
||||
{ type: 'TaxSummary', label: 'Tax Summary', filters: ['range'], hint: 'Income tax computation for a period.' },
|
||||
]
|
||||
|
||||
const ALL_ACCOUNTS = 'all'
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
const yearStart = () => `${new Date().getFullYear()}-01-01`
|
||||
|
||||
export function FinanceReportClient() {
|
||||
const allowed = usePermission('finance-reports.view')
|
||||
const [type, setType] = useState<FinanceReportType>('TrialBalance')
|
||||
const [asOfDate, setAsOfDate] = useState(today())
|
||||
const [periodStart, setPeriodStart] = useState(yearStart())
|
||||
const [periodEnd, setPeriodEnd] = useState(today())
|
||||
const [accountCode, setAccountCode] = useState(ALL_ACCOUNTS)
|
||||
const [accounts, setAccounts] = useState<GlAccountOption[]>([])
|
||||
const [budgetId, setBudgetId] = useState('')
|
||||
const [blocks, setBlocks] = useState<Block[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busy, setBusy] = useState<FileFormat | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) return
|
||||
financeReports.accounts().then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [allowed])
|
||||
|
||||
const report = REPORTS.find(r => r.type === type)!
|
||||
|
||||
function params(): FinanceReportParams {
|
||||
const p: FinanceReportParams = {}
|
||||
if (report.filters.includes('asOf')) p.asOfDate = asOfDate
|
||||
if (report.filters.includes('range')) { p.periodStart = periodStart; p.periodEnd = periodEnd }
|
||||
if (report.filters.includes('account') && accountCode !== ALL_ACCOUNTS) p.accountCode = accountCode
|
||||
if (report.filters.includes('budget')) p.budgetId = Number(budgetId) || undefined
|
||||
return p
|
||||
}
|
||||
|
||||
function changeType(next: FinanceReportType) {
|
||||
setType(next)
|
||||
setBlocks(null)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await financeReports.get(type, params())
|
||||
setBlocks(toBlocks(type, data))
|
||||
} catch (err) {
|
||||
setBlocks(null)
|
||||
toast.add({ title: 'Could not load report', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function download(format: FileFormat) {
|
||||
setBusy(format)
|
||||
try {
|
||||
await financeReports.download(type, params(), format)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Download 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 view financial reports.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Financial Reports</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Produced by the General Ledger from sales, purchases and payments. Cash, bank and cheque balances are mirrored from the Payments module.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{REPORTS.map(r => (
|
||||
<Button key={r.type} size="sm" variant={type === r.type ? 'default' : 'outline'} onClick={() => changeType(r.type)}>
|
||||
{r.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">{report.hint}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{report.filters.includes('asOf') && (
|
||||
<Input className="w-44" type="date" value={asOfDate} onChange={e => setAsOfDate(e.target.value)} aria-label="As of date" />
|
||||
)}
|
||||
{report.filters.includes('range') && (
|
||||
<>
|
||||
<Input className="w-44" type="date" value={periodStart} onChange={e => setPeriodStart(e.target.value)} aria-label="From" />
|
||||
<Input className="w-44" type="date" value={periodEnd} onChange={e => setPeriodEnd(e.target.value)} aria-label="To" />
|
||||
</>
|
||||
)}
|
||||
{report.filters.includes('account') && (
|
||||
<Select value={accountCode} onValueChange={v => setAccountCode(v ?? ALL_ACCOUNTS)}>
|
||||
<SelectTrigger className="w-72"><SelectValue placeholder="Account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ACCOUNTS}>All accounts</SelectItem>
|
||||
{accounts.map(a => <SelectItem key={a.accountCode} value={a.accountCode}>{a.accountCode} — {a.accountName}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{report.filters.includes('budget') && (
|
||||
<Input className="w-40" type="number" placeholder="Budget id" value={budgetId} onChange={e => setBudgetId(e.target.value)} />
|
||||
)}
|
||||
<Button size="sm" onClick={run} disabled={loading}>{loading ? 'Loading…' : 'Run report'}</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => download('Pdf')}>
|
||||
{busy === 'Pdf' ? 'Downloading…' : 'Download PDF'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => download('Csv')}>
|
||||
{busy === 'Csv' ? 'Downloading…' : 'Download CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blocks === null ? (
|
||||
<p className="text-sm text-muted-foreground">Choose the filters and run the report.</p>
|
||||
) : blocks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No data for this selection.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{blocks.map((b, i) => <BlockTable key={i} block={b} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockTable({ block }: { block: Block }) {
|
||||
const numeric = new Set(block.numericCols)
|
||||
return (
|
||||
<Card className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-2">
|
||||
{block.title && <h2 className="text-base font-semibold text-foreground">{block.title}</h2>}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{block.headers.map((h, c) => (
|
||||
<TableHead key={c} className={numeric.has(c) ? 'text-right' : undefined}>{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{block.rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={block.headers.length} className="text-muted-foreground">No entries.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
block.rows.map((row, r) => (
|
||||
<TableRow key={r}>
|
||||
{row.map((cell, c) => (
|
||||
<TableCell key={c} className={numeric.has(c) ? 'text-right tabular-nums' : undefined}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{block.footer && (
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
{block.footer.map((cell, c) => (
|
||||
<TableCell key={c} className={`font-semibold ${numeric.has(c) ? 'text-right tabular-nums' : ''}`}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { FinanceReportType } from '@/lib/api/finance-reports'
|
||||
|
||||
/** A table the report page renders; every GL report is converted into one or more of these. */
|
||||
export interface Block {
|
||||
title?: string
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
footer?: string[]
|
||||
numericCols: number[]
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>
|
||||
|
||||
const money = (v: unknown) =>
|
||||
Number(v ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const text = (v: unknown) => (v == null ? '' : String(v))
|
||||
|
||||
const sum = (rows: Obj[], key: string) => rows.reduce((t, r) => t + Number(r[key] ?? 0), 0)
|
||||
|
||||
const humanize = (key: string) =>
|
||||
key.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()).replace(/\bApit\b/, 'APIT').replace(/\bWht\b/, 'WHT')
|
||||
|
||||
function summary(title: string | undefined, entries: [string, unknown][]): Block {
|
||||
return { title, headers: ['', 'Amount'], rows: entries.map(([k, v]) => [k, money(v)]), numericCols: [1] }
|
||||
}
|
||||
|
||||
function section(title: string, s: Obj | undefined, amountKey: string, totalLabel: string): Block | null {
|
||||
const lines = (s?.lines as Obj[] | undefined) ?? []
|
||||
if (lines.length === 0 && !Number(s?.total ?? 0)) return null
|
||||
return {
|
||||
title,
|
||||
headers: ['Code', 'Account', 'Amount'],
|
||||
rows: lines.map(l => [text(l.accountCode), text(l.accountName), money(l[amountKey])]),
|
||||
footer: ['', totalLabel, money(s?.total)],
|
||||
numericCols: [2],
|
||||
}
|
||||
}
|
||||
|
||||
const compact = (blocks: (Block | null)[]) => blocks.filter((b): b is Block => b !== null)
|
||||
|
||||
export function toBlocks(type: FinanceReportType, data: unknown): Block[] {
|
||||
switch (type) {
|
||||
case 'TrialBalance': {
|
||||
const rows = (data as Obj[]) ?? []
|
||||
return [{
|
||||
headers: ['Code', 'Account', 'Debit', 'Credit'],
|
||||
rows: rows.map(r => [text(r.accountCode), text(r.accountName), money(r.debit), money(r.credit)]),
|
||||
footer: ['', 'Total', money(sum(rows, 'debit')), money(sum(rows, 'credit'))],
|
||||
numericCols: [2, 3],
|
||||
}]
|
||||
}
|
||||
|
||||
case 'GeneralLedger': {
|
||||
const rows = (data as Obj[]) ?? []
|
||||
return [{
|
||||
headers: ['Date', 'Journal', 'Account', 'Narration', 'Debit', 'Credit'],
|
||||
rows: rows.map(r => [
|
||||
text(r.entryDate), text(r.journalNo), `${text(r.accountCode)} ${text(r.accountName)}`, text(r.narration),
|
||||
r.debitAmount ? money(r.debitAmount) : '', r.creditAmount ? money(r.creditAmount) : '',
|
||||
]),
|
||||
footer: ['', '', '', 'Total', money(sum(rows, 'debitAmount')), money(sum(rows, 'creditAmount'))],
|
||||
numericCols: [4, 5],
|
||||
}]
|
||||
}
|
||||
|
||||
case 'ProfitAndLoss': {
|
||||
const d = (data as Obj) ?? {}
|
||||
return compact([
|
||||
section('Sales', d.sales as Obj, 'amount', 'Total sales'),
|
||||
section('Cost of sales', d.costOfSales as Obj, 'amount', 'Total cost of sales'),
|
||||
summary(undefined, [['Gross profit', d.grossProfit]]),
|
||||
section('Other income', d.otherIncome as Obj, 'amount', 'Total other income'),
|
||||
section('Distribution expenses', d.distributionExpenses as Obj, 'amount', 'Total'),
|
||||
section('Administration expenses', d.administrationExpenses as Obj, 'amount', 'Total'),
|
||||
section('Other expenses', d.otherExpenses as Obj, 'amount', 'Total'),
|
||||
section('Financial expenses', d.financialExpenses as Obj, 'amount', 'Total'),
|
||||
section('Unclassified accounts', d.unclassified as Obj, 'amount', 'Total'),
|
||||
summary(undefined, [['Net profit for the period', d.netProfitForPeriod]]),
|
||||
])
|
||||
}
|
||||
|
||||
case 'BalanceSheet': {
|
||||
const d = (data as Obj) ?? {}
|
||||
return compact([
|
||||
section('Non-current assets', d.nonCurrentAssets as Obj, 'balance', 'Total'),
|
||||
section('Current assets', d.currentAssets as Obj, 'balance', 'Total'),
|
||||
section('Unclassified assets', d.unclassifiedAssets as Obj, 'balance', 'Total'),
|
||||
summary(undefined, [['Total assets', d.totalAssets]]),
|
||||
section('Equity', d.equity as Obj, 'balance', 'Total equity'),
|
||||
section('Non-current liabilities', d.nonCurrentLiabilities as Obj, 'balance', 'Total'),
|
||||
section('Current liabilities', d.currentLiabilities as Obj, 'balance', 'Total'),
|
||||
section('Unclassified liabilities', d.unclassifiedLiabilities as Obj, 'balance', 'Total'),
|
||||
summary(undefined, [['Total equity and liabilities', d.totalEquityAndLiabilities]]),
|
||||
])
|
||||
}
|
||||
|
||||
case 'CashFlow': {
|
||||
const d = (data as Obj) ?? {}
|
||||
const op = (d.operatingActivities as Obj) ?? {}
|
||||
const adjustments = (op.nonCashAdjustments as Obj[]) ?? []
|
||||
const changes = (op.workingCapitalChanges as Obj[]) ?? []
|
||||
const lines = (a: unknown) => ((a as Obj)?.lines as Obj[]) ?? []
|
||||
return [
|
||||
{
|
||||
title: 'Operating activities',
|
||||
headers: ['Item', 'Amount'],
|
||||
rows: [
|
||||
['Profit for the period', money(op.profitForPeriod)],
|
||||
...adjustments.map(a => [text(a.description), money(a.amount)]),
|
||||
...changes.map(c => [`${text(c.accountName)} (${text(c.direction)})`, money(c.changeAmount)]),
|
||||
],
|
||||
footer: ['Net cash from operating activities', money(op.netCashFromOperatingActivities)],
|
||||
numericCols: [1],
|
||||
},
|
||||
{
|
||||
title: 'Investing activities',
|
||||
headers: ['Item', 'Amount'],
|
||||
rows: lines(d.investingActivities).map(l => [text(l.accountName), money(l.amount)]),
|
||||
footer: ['Net cash from investing activities', money((d.investingActivities as Obj)?.netCashFromInvestingActivities)],
|
||||
numericCols: [1],
|
||||
},
|
||||
{
|
||||
title: 'Financing activities',
|
||||
headers: ['Item', 'Amount'],
|
||||
rows: lines(d.financingActivities).map(l => [text(l.accountName), money(l.amount)]),
|
||||
footer: ['Net cash from financing activities', money((d.financingActivities as Obj)?.netCashFromFinancingActivities)],
|
||||
numericCols: [1],
|
||||
},
|
||||
summary(undefined, [
|
||||
['Net increase / decrease in cash', d.netIncreaseDecreaseInCash],
|
||||
['Opening cash balance', d.openingCashBalance],
|
||||
['Closing cash balance', d.closingCashBalance],
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
case 'BudgetVsActual': {
|
||||
const rows = (data as Obj[]) ?? []
|
||||
return [{
|
||||
headers: ['Account', 'Period', 'Budgeted', 'Actual', 'Variance'],
|
||||
rows: rows.map(r => [`${text(r.accountCode)} ${text(r.accountName)}`, text(r.periodId), money(r.budgetedAmount), money(r.actualAmount), money(r.variance)]),
|
||||
numericCols: [2, 3, 4],
|
||||
}]
|
||||
}
|
||||
|
||||
case 'TaxSummary': {
|
||||
const d = (data as Obj) ?? {}
|
||||
const entries = Object.entries(d).filter(([k]) => k !== 'periodStart' && k !== 'periodEnd')
|
||||
return [summary(`Income tax computation (${text(d.periodStart)} to ${text(d.periodEnd)})`, entries.map(([k, v]) => [humanize(k), v]))]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { FinanceReportClient } from './_components/finance-report-client'
|
||||
|
||||
export default function FinanceReportPage() {
|
||||
return <FinanceReportClient />
|
||||
}
|
||||
@@ -24,9 +24,12 @@ import {
|
||||
items as itemsApi,
|
||||
priceLists as priceListsApi,
|
||||
users as usersApi,
|
||||
invoiceCreationMode as invoiceCreationModeApi,
|
||||
DEFAULT_INVOICE_CREATION_MODE,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||
import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api'
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
@@ -56,6 +59,11 @@ export default function EditShopPOPage() {
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [confirmedByUserId, setConfirmedByUserId] = useState('')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||
|
||||
useEffect(() => {
|
||||
invoiceCreationModeApi.get().then(setMode).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -98,9 +106,14 @@ export default function EditShopPOPage() {
|
||||
if (!po || !confirmedByUserId) return
|
||||
setConfirming(true)
|
||||
try {
|
||||
const updated = await shopPosApi.confirm(po.id, confirmedByUserId)
|
||||
const updated = mode === 'ModeA'
|
||||
? await shopPosApi.confirmAndCreateInvoice(po.id, confirmedByUserId)
|
||||
: await shopPosApi.confirm(po.id, confirmedByUserId)
|
||||
setPo(updated)
|
||||
toast.add({ title: 'Shop PO confirmed', type: 'success' })
|
||||
toast.add({
|
||||
title: mode === 'ModeA' ? 'Shop PO confirmed and invoice created' : 'Shop PO confirmed',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
|
||||
@@ -21,7 +21,15 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { shopPos as shopPosApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import {
|
||||
shopPos as shopPosApi,
|
||||
users as usersApi,
|
||||
invoiceCreationMode as invoiceCreationModeApi,
|
||||
DEFAULT_INVOICE_CREATION_MODE,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||
import type { ShopPOResponse, UserResponse } from '@/types/api'
|
||||
|
||||
const schema = z.object({
|
||||
@@ -39,6 +47,7 @@ interface ConfirmShopPODialogProps {
|
||||
|
||||
export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmShopPODialogProps) {
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -51,14 +60,20 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
invoiceCreationModeApi.get().then(setMode).catch(() => {})
|
||||
}, [open])
|
||||
|
||||
if (!po) return null
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await shopPosApi.confirm(po!.id, values.confirmedByUserId)
|
||||
toast.add({ title: 'Shop PO confirmed', type: 'success' })
|
||||
if (mode === 'ModeA') {
|
||||
await shopPosApi.confirmAndCreateInvoice(po!.id, values.confirmedByUserId)
|
||||
toast.add({ title: 'Shop PO confirmed and invoice created', type: 'success' })
|
||||
} else {
|
||||
await shopPosApi.confirm(po!.id, values.confirmedByUserId)
|
||||
toast.add({ title: 'Shop PO confirmed', type: 'success' })
|
||||
}
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
@@ -71,7 +86,10 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Shop PO</DialogTitle>
|
||||
<DialogDescription>{po.storeName} — Rs. {po.totalAmount.toLocaleString()}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{po.storeName} — Rs. {po.totalAmount.toLocaleString()}
|
||||
{mode === 'ModeA' && ' · a draft invoice will be created'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, ArrowLeft01Icon, Delete02Icon, Undo02Icon } from '@hugeicons/core-free-icons'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
invoices as invoicesApi,
|
||||
users as usersApi,
|
||||
items as itemsApi,
|
||||
priceLists as priceListsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, UserResponse } from '@/types/api'
|
||||
|
||||
interface AddedLine {
|
||||
key: string
|
||||
itemId: string
|
||||
name: string
|
||||
qty: string
|
||||
price: string
|
||||
}
|
||||
|
||||
const NEW_PREFIX = 'new:'
|
||||
const isNew = (item: InvoiceItemResponse) => item.id.startsWith(NEW_PREFIX)
|
||||
const addedKey = (item: InvoiceItemResponse) => item.id.slice(NEW_PREFIX.length)
|
||||
|
||||
// Full-page editor for a Draft invoice, on the invoice template: change quantity/price, delete (strike)
|
||||
// lines and add items that were not on the PO. Each save is recorded as a new invoice version.
|
||||
export default function InvoiceEditPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams<{ id: string }>()
|
||||
const invoiceId = params.id
|
||||
const backToInvoice = () => router.push(`/sales/invoices/${invoiceId}`)
|
||||
|
||||
const [full, setFull] = useState<InvoiceResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [qty, setQty] = useState<Record<string, string>>({})
|
||||
const [price, setPrice] = useState<Record<string, string>>({})
|
||||
const [removed, setRemoved] = useState<Record<string, boolean>>({})
|
||||
const [added, setAdded] = useState<AddedLine[]>([])
|
||||
const [catalogue, setCatalogue] = useState<ItemSummaryResponse[]>([])
|
||||
const [pickItemId, setPickItemId] = useState('')
|
||||
const [pickQty, setPickQty] = useState('1')
|
||||
const [pickPrice, setPickPrice] = useState('')
|
||||
const [userId, setUserId] = useState('')
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
invoicesApi.get(invoiceId)
|
||||
.then((inv) => {
|
||||
if (inv.status !== 'Draft') {
|
||||
toast.add({ title: 'Only Draft invoices can be edited', type: 'error' })
|
||||
router.replace(`/sales/invoices/${invoiceId}`)
|
||||
return
|
||||
}
|
||||
setFull(inv)
|
||||
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
|
||||
setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)])))
|
||||
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' })
|
||||
router.push('/sales/orders')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
usersApi.list()
|
||||
.then(setUsersList)
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
itemsApi.list({ pageSize: 200 })
|
||||
.then((res) => setCatalogue(getPaginatedItems(res)))
|
||||
.catch((err) => toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' }))
|
||||
}, [invoiceId, router])
|
||||
|
||||
// The template renders this copy so line amounts and the total follow what is typed.
|
||||
const live = useMemo<InvoiceResponse | null>(() => {
|
||||
if (!full) return null
|
||||
const existing = full.items.map((i) => ({
|
||||
...i,
|
||||
quantity: Number(qty[i.id]) || 0,
|
||||
unitPrice: Number(price[i.id]) || 0,
|
||||
isRemoved: !!removed[i.id],
|
||||
}))
|
||||
const extra: InvoiceItemResponse[] = added.map((a) => ({
|
||||
id: NEW_PREFIX + a.key,
|
||||
shopPOItemId: null,
|
||||
itemId: a.itemId,
|
||||
itemName: a.name,
|
||||
orderedQuantity: null,
|
||||
quantity: Number(a.qty) || 0,
|
||||
unitPrice: Number(a.price) || 0,
|
||||
isRemoved: false,
|
||||
}))
|
||||
const items = [...existing, ...extra]
|
||||
const totalAmount = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||
return { ...full, items, totalAmount }
|
||||
}, [full, qty, price, removed, added])
|
||||
|
||||
// Items not already on the invoice (a deleted line can be restored with Undo instead).
|
||||
const availableItems = catalogue.filter((c) => !live?.items.some((i) => i.itemId === c.id))
|
||||
|
||||
// Picking an item fills in its price: the store's price list, falling back to the item's default price.
|
||||
async function pickItem(itemId: string) {
|
||||
setPickItemId(itemId)
|
||||
const item = catalogue.find((c) => c.id === itemId)
|
||||
if (!item || !full) return
|
||||
setPickPrice(String(item.salePrice))
|
||||
try {
|
||||
const res = await priceListsApi.resolve({
|
||||
itemId,
|
||||
storeId: full.storeId,
|
||||
saleDate: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
setPickPrice(String(res.price))
|
||||
} catch {
|
||||
// No price list entry for this store — keep the default price.
|
||||
}
|
||||
}
|
||||
|
||||
// The line currently typed into the add row, or null when nothing valid is picked.
|
||||
function pendingLine(): AddedLine | null {
|
||||
const item = catalogue.find((c) => c.id === pickItemId)
|
||||
if (!item || !(Number(pickQty) > 0) || pickPrice === '' || Number(pickPrice) < 0) return null
|
||||
return { key: crypto.randomUUID(), itemId: item.id, name: item.name, qty: pickQty, price: pickPrice }
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const line = pendingLine()
|
||||
if (!line) {
|
||||
toast.add({ title: 'Pick an item and enter a quantity above zero', type: 'error' })
|
||||
return
|
||||
}
|
||||
setAdded((a) => [...a, line])
|
||||
setPickItemId('')
|
||||
setPickQty('1')
|
||||
setPickPrice('')
|
||||
}
|
||||
|
||||
const changeSummary = useMemo(() => {
|
||||
if (!full) return []
|
||||
const edited = full.items.filter(
|
||||
(i) => !removed[i.id] && (Number(qty[i.id]) !== i.quantity || Number(price[i.id]) !== i.unitPrice),
|
||||
).length
|
||||
const struck = full.items.filter((i) => !!removed[i.id] !== i.isRemoved).length
|
||||
return [
|
||||
added.length && `${added.length} added`,
|
||||
struck && `${struck} removed/restored`,
|
||||
edited && `${edited} edited`,
|
||||
].filter(Boolean) as string[]
|
||||
}, [full, qty, price, removed, added])
|
||||
|
||||
const dirty = changeSummary.length > 0
|
||||
|
||||
// Warn before the tab is closed/reloaded with unsaved edits.
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [dirty])
|
||||
|
||||
async function save() {
|
||||
if (!full) return
|
||||
if (!userId) {
|
||||
toast.add({ title: 'Select who is editing', type: 'error' })
|
||||
return
|
||||
}
|
||||
// An item picked in the add row but not yet added is included, so it is not silently lost.
|
||||
const pending = pendingLine()
|
||||
const newLines = pending ? [...added, pending] : added
|
||||
setSaving(true)
|
||||
try {
|
||||
await invoicesApi.updateItems(
|
||||
full.id,
|
||||
[
|
||||
...full.items.map((i) => ({
|
||||
invoiceItemId: i.id,
|
||||
quantity: Number(qty[i.id]),
|
||||
unitPrice: Number(price[i.id]),
|
||||
isRemoved: !!removed[i.id],
|
||||
})),
|
||||
...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })),
|
||||
],
|
||||
userId,
|
||||
)
|
||||
toast.add({ title: 'Invoice updated', type: 'success' })
|
||||
// Navigating away right after a successful save; clear the unsaved-changes guard first.
|
||||
setAdded([])
|
||||
setQty(Object.fromEntries(full.items.map((i) => [i.id, String(i.quantity)])))
|
||||
setPrice(Object.fromEntries(full.items.map((i) => [i.id, String(i.unitPrice)])))
|
||||
setRemoved(Object.fromEntries(full.items.map((i) => [i.id, i.isRemoved])))
|
||||
backToInvoice()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to update invoice', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="outline" size="sm" onClick={backToInvoice}>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Edit Draft Invoice</h1>
|
||||
{full && <Badge variant="secondary">{full.status}</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Change quantities and prices, delete lines, or add items. Quantity on a PO line cannot exceed the
|
||||
ordered quantity. Every save creates a new version.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || !live ? (
|
||||
<p className="text-sm text-muted-foreground">Loading invoice…</p>
|
||||
) : (
|
||||
<>
|
||||
<InvoiceTemplate
|
||||
invoice={live}
|
||||
renderQuantity={(item) => (
|
||||
<Input
|
||||
type="number" min={0} max={item.orderedQuantity ?? undefined}
|
||||
className="ml-auto h-8 w-20 text-right"
|
||||
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.qty ?? '' : qty[item.id] ?? ''}
|
||||
onChange={(e) => isNew(item)
|
||||
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, qty: e.target.value } : l))
|
||||
: setQty((q) => ({ ...q, [item.id]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
renderUnitPrice={(item) => (
|
||||
<Input
|
||||
type="number" min={0} step="0.01"
|
||||
className="ml-auto h-8 w-28 text-right"
|
||||
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.price ?? '' : price[item.id] ?? ''}
|
||||
onChange={(e) => isNew(item)
|
||||
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, price: e.target.value } : l))
|
||||
: setPrice((p) => ({ ...p, [item.id]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
renderActions={(item) => (
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon-sm"
|
||||
className={item.isRemoved ? 'text-muted-foreground' : 'text-destructive hover:text-destructive'}
|
||||
aria-label={item.isRemoved ? 'Restore line' : 'Delete line'}
|
||||
title={item.isRemoved ? 'Restore line' : 'Delete line'}
|
||||
onClick={() => isNew(item)
|
||||
? setAdded((a) => a.filter((l) => l.key !== addedKey(item)))
|
||||
: setRemoved((r) => ({ ...r, [item.id]: !r[item.id] }))}
|
||||
>
|
||||
<HugeiconsIcon icon={item.isRemoved ? Undo02Icon : Delete02Icon} size={16} />
|
||||
</Button>
|
||||
)}
|
||||
footerRow={
|
||||
// Cells line up with the table columns: Item · Qty · Unit Price · Amount · action.
|
||||
<tr className="bg-neutral-50 print:hidden" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }}>
|
||||
<td className="px-2 py-3">
|
||||
<Select value={pickItemId} onValueChange={(v) => pickItem(v ?? '')}>
|
||||
<SelectTrigger className="h-8 w-full"><SelectValue placeholder="Add an item…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableItems.length === 0
|
||||
? <div className="px-3 py-2 text-sm text-muted-foreground">No more items to add</div>
|
||||
: availableItems.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name} <span className="text-muted-foreground">· {c.sku}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<Input
|
||||
type="number" min={1} aria-label="Quantity" className="ml-auto h-8 w-20 text-right"
|
||||
value={pickQty} onChange={(e) => setPickQty(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<Input
|
||||
type="number" min={0} step="0.01" aria-label="Unit price" placeholder="Price"
|
||||
className="ml-auto h-8 w-28 text-right"
|
||||
value={pickPrice} onChange={(e) => setPickPrice(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right text-sm text-neutral-500">
|
||||
{pickItemId ? ((Number(pickQty) || 0) * (Number(pickPrice) || 0)).toLocaleString('en-US', { minimumFractionDigits: 2 }) : ''}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right">
|
||||
<Button
|
||||
type="button" size="icon-sm" aria-label="Add item" title="Add item (Enter)"
|
||||
onClick={addItem} disabled={!pickItemId}
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-[210mm] flex-wrap items-center justify-between gap-3">
|
||||
<div className="w-64">
|
||||
<Select value={userId} onValueChange={(v) => setUserId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="Edited by (temporary)..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{usersList.map((u) => <SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dirty ? `Unsaved: ${changeSummary.join(', ')}` : 'No changes yet'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToInvoice}>Cancel</Button>
|
||||
<Button type="button" onClick={save} disabled={saving || (!dirty && !pendingLine())}>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,19 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
|
||||
import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
|
||||
import type { InvoiceResponse, InvoiceStatus, InvoiceVersionResponse, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
|
||||
|
||||
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
|
||||
import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog'
|
||||
|
||||
const STAGE_LABEL: Record<InvoiceVersionResponse['stage'], string> = {
|
||||
Created: 'Created',
|
||||
DraftEdited: 'Draft edited',
|
||||
Printed: 'Printed',
|
||||
Finalized: 'Finalized',
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
|
||||
Draft: 'secondary',
|
||||
Printed: 'outline',
|
||||
@@ -61,10 +68,12 @@ export default function InvoiceViewPage() {
|
||||
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
|
||||
const [actionOpen, setActionOpen] = useState(false)
|
||||
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
||||
const [versions, setVersions] = useState<InvoiceVersionResponse[]>([])
|
||||
|
||||
const fetchInvoice = useCallback(async () => {
|
||||
const inv = await invoicesApi.get(params.id)
|
||||
const [inv, vers] = await Promise.all([invoicesApi.get(params.id), invoicesApi.versions(params.id)])
|
||||
setInvoice(inv)
|
||||
setVersions(vers)
|
||||
return inv
|
||||
}, [params.id])
|
||||
|
||||
@@ -139,11 +148,21 @@ export default function InvoiceViewPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{invoice.status === 'Draft' && (
|
||||
<Button type="button" variant="outline" onClick={() => router.push(`/sales/invoices/${invoice.id}/edit`)}>
|
||||
Edit Items
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === 'Draft' && (
|
||||
<Button type="button" onClick={handlePrint}>
|
||||
Print Invoice
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status !== 'Draft' && (
|
||||
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${invoice.id}/print`, '_blank')}>
|
||||
Reprint
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === 'Printed' && (
|
||||
<Button type="button" onClick={handleFinalize}>
|
||||
Finalize Invoice
|
||||
@@ -160,6 +179,7 @@ export default function InvoiceViewPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
|
||||
<Stat label="Total" value={fmtAmount(invoice.totalAmount)} />
|
||||
{invoice.discountAmount > 0 && <Stat label="Scheme discount (included)" value={fmtAmount(invoice.discountAmount)} />}
|
||||
<Stat label="Paid" value={fmtAmount(paidAmount)} />
|
||||
<Stat label="Balance" value={fmtAmount(balance)} />
|
||||
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
|
||||
@@ -167,6 +187,84 @@ export default function InvoiceViewPage() {
|
||||
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Items</h3>
|
||||
<p className="text-sm text-muted-foreground">Ordered quantity is the original PO; quantity is what is billed now.</p>
|
||||
</div>
|
||||
<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="w-24 px-3 py-2 text-right font-medium">Ordered</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">Billed</th>
|
||||
<th className="w-28 px-3 py-2 text-right font-medium">Unit price</th>
|
||||
<th className="w-32 px-3 py-2 text-right font-medium">Line total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
|
||||
</tr>
|
||||
) : (
|
||||
invoice.items.map((i) => (
|
||||
<tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}>
|
||||
<td className="px-3 py-2">
|
||||
{i.itemName}
|
||||
{i.orderedQuantity === null && <Badge variant="outline" className="ml-2 no-underline">Added</Badge>}
|
||||
{i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{i.orderedQuantity ?? '—'}</td>
|
||||
<td className={`px-3 py-2 text-right ${!i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
|
||||
{i.quantity}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{fmtAmount(i.unitPrice)}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Version history</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quantities after each change, so you can compare the original, printed and finalized copies.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="w-16 px-3 py-2 text-left font-medium">Version</th>
|
||||
<th className="w-32 px-3 py-2 text-left font-medium">Stage</th>
|
||||
<th className="w-40 px-3 py-2 text-left font-medium">When</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Quantities</th>
|
||||
<th className="w-32 px-3 py-2 text-right font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.map((v) => (
|
||||
<tr key={v.id} className="border-t align-top">
|
||||
<td className="px-3 py-2 font-mono">v{v.versionNumber}</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline">{STAGE_LABEL[v.stage]}</Badge></td>
|
||||
<td className="px-3 py-2">{fmtDateTime(v.createdAt)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{v.items.map((i) => (i.isRemoved ? `${i.itemName} (removed)` : `${i.itemName} × ${i.quantity}`)).join(', ') || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{fmtAmount(v.totalAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Payments</h3>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { invoices as invoicesApi, shopPos as shopPosApi, users as usersApi, getApiError } from '@/lib/api/index'
|
||||
import type { ShopPOResponse, UserResponse } from '@/types/api'
|
||||
|
||||
const schema = z.object({
|
||||
shopPOId: z.string().min(1, 'Shop PO is required'),
|
||||
userId: z.string().min(1, 'User is required'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
interface CreateInvoiceDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||
const truncId = (id: string) => id.substring(0, 8)
|
||||
|
||||
export function CreateInvoiceDialog({ open, onClose, onSuccess }: CreateInvoiceDialogProps) {
|
||||
const [pos, setPos] = useState<ShopPOResponse[]>([])
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { shopPOId: '', userId: '' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ shopPOId: '', userId: '' })
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
shopPosApi.confirmedWithoutInvoice(),
|
||||
usersApi.list(),
|
||||
])
|
||||
.then(([posRes, usersRes]) => {
|
||||
setPos(posRes)
|
||||
setUsersList(usersRes)
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load data', description: getApiError(err), type: 'error' }))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await invoicesApi.create(values.shopPOId, values.userId)
|
||||
toast.add({ title: 'Invoice created', type: 'success' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to create invoice', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Invoice</DialogTitle>
|
||||
<DialogDescription>Generate an invoice from a confirmed Shop PO that doesn't have one yet.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shopPOId"
|
||||
label="Shop PO"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading || pos.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? 'Loading…' : pos.length === 0 ? 'No eligible Shop POs' : 'Select Shop PO...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pos.map((po) => (
|
||||
<SelectItem key={po.id} value={po.id}>
|
||||
{po.storeName} — {fmtAmount(po.totalAmount)} [{truncId(po.id)}]
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userId"
|
||||
label="Created By"
|
||||
description="Temporary — required until auth resolves the logged-in user automatically."
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{usersList.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting || pos.length === 0}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Create Invoice'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { invoices as invoicesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { InvoiceResponse, UserResponse } from '@/types/api'
|
||||
|
||||
@@ -39,13 +40,17 @@ interface InvoiceActionDialogProps {
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
print: { title: 'Mark Invoice as Printed', label: 'Printed By', action: 'Mark as Printed', success: 'Invoice marked as printed' },
|
||||
print: { title: 'Print Invoice', label: 'Printed By', action: 'Print Invoice', success: 'Invoice printed' },
|
||||
finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' },
|
||||
}
|
||||
|
||||
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const copy = COPY[mode]
|
||||
const [qty, setQty] = useState<Record<string, string>>({})
|
||||
const [removed, setRemoved] = useState<Record<string, boolean>>({})
|
||||
// Rows from the list endpoint carry no line items, so the full invoice is loaded for the finalize table.
|
||||
const [full, setFull] = useState<InvoiceResponse | null>(null)
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -55,6 +60,18 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ userId: '' })
|
||||
setFull(null)
|
||||
setQty({})
|
||||
setRemoved({})
|
||||
if (invoice) {
|
||||
invoicesApi.get(invoice.id)
|
||||
.then((inv) => {
|
||||
setFull(inv)
|
||||
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
|
||||
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' }))
|
||||
}
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
@@ -62,21 +79,35 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
const lines = full?.items ?? []
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
// Opened synchronously, while the click still counts as a user gesture, so the browser allows the new tab.
|
||||
const printTab = mode === 'print' ? window.open('', '_blank') : null
|
||||
try {
|
||||
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId)
|
||||
else await invoicesApi.finalize(invoice!.id, values.userId)
|
||||
if (mode === 'print') {
|
||||
await invoicesApi.print(invoice!.id, values.userId)
|
||||
if (printTab) printTab.location.href = `/invoices/${invoice!.id}/print`
|
||||
} else {
|
||||
if (!full) throw new Error('Invoice is still loading')
|
||||
// Only lines the agent changed on the printed copy are sent as adjustments.
|
||||
const adjustments = lines
|
||||
.filter((i) => Number(qty[i.id]) !== i.quantity || !!removed[i.id] !== i.isRemoved)
|
||||
.map((i) => ({ invoiceItemId: i.id, adjustedQuantity: Number(qty[i.id]), isRemoved: !!removed[i.id] }))
|
||||
await invoicesApi.finalize(invoice!.id, values.userId, adjustments)
|
||||
}
|
||||
toast.add({ title: copy.success, type: 'success' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
printTab?.close()
|
||||
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
|
||||
@@ -106,6 +137,45 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{mode === 'finalize' && lines.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Edits from the printed copy</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="w-20 px-3 py-2 text-right font-medium">Printed</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">Final qty</th>
|
||||
<th className="w-24 px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((i) => (
|
||||
<tr key={i.id} className={`border-t ${removed[i.id] ? "text-muted-foreground line-through" : ""}`}>
|
||||
<td className="px-3 py-2">{i.itemName}</td>
|
||||
<td className="px-3 py-2 text-right">{i.quantity}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number" min={0} max={i.orderedQuantity ?? undefined} disabled={!!removed[i.id]}
|
||||
className="h-8 text-right"
|
||||
value={qty[i.id] ?? ''}
|
||||
onChange={(e) => setQty((q) => ({ ...q, [i.id]: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right no-underline">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}>
|
||||
{removed[i.id] ? 'Undo' : 'Remove'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
||||
|
||||
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Draft: 'secondary',
|
||||
Printed: 'secondary',
|
||||
Finalized: 'default',
|
||||
}
|
||||
|
||||
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Pending: 'secondary',
|
||||
PartiallyPaid: 'secondary',
|
||||
Paid: 'default',
|
||||
}
|
||||
|
||||
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||
const truncId = (id: string) => id.substring(0, 8)
|
||||
|
||||
const fmtDateTime = (v: string | null) =>
|
||||
v
|
||||
? new Date(v).toLocaleString('en-GB', {
|
||||
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
: '—'
|
||||
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
|
||||
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
|
||||
import type { InvoiceResponse } from '@/types/api'
|
||||
|
||||
interface OrderViewSheetProps {
|
||||
open: boolean
|
||||
@@ -37,36 +17,34 @@ interface OrderViewSheetProps {
|
||||
invoice: InvoiceResponse | 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>
|
||||
)
|
||||
}
|
||||
|
||||
// Previews the invoice as the printed document. The list rows carry no line items, so the full invoice is fetched.
|
||||
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
|
||||
const doc = useInvoiceDocument(invoice?.id, open)
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invoice Details</DialogTitle>
|
||||
<DialogTitle>Invoice Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} />
|
||||
<Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} />
|
||||
<Row label="Store" value={invoice.storeName} />
|
||||
<Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} />
|
||||
<Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} />
|
||||
<Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} />
|
||||
<Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
|
||||
<Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} />
|
||||
<Row label="Created At" value={fmtDateTime(invoice.createdAt)} />
|
||||
</div>
|
||||
{doc.invoice ? (
|
||||
<InvoiceTemplate invoice={doc.invoice} payments={doc.payments} customer={doc.customer} />
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{doc.error ? `Failed to load invoice: ${doc.error}` : 'Loading invoice…'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{doc.invoice && doc.invoice.status !== 'Draft' && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${doc.invoice!.id}/print`, '_blank')}>
|
||||
Open print view
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table'
|
||||
import type { TableConfig } from '@/components/shared/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { invoices as invoicesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import { invoices as invoicesApi, invoiceCreationMode as invoiceCreationModeApi, DEFAULT_INVOICE_CREATION_MODE, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
||||
|
||||
import { OrderViewSheet } from './order-view-sheet'
|
||||
import { InvoiceActionDialog } from './invoice-action-dialog'
|
||||
import { CreateInvoiceDialog } from './create-invoice-dialog'
|
||||
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
@@ -32,6 +36,7 @@ const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'd
|
||||
}
|
||||
|
||||
export function OrdersClient() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<InvoiceResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -44,6 +49,12 @@ export function OrdersClient() {
|
||||
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
||||
const [paymentOpen, setPaymentOpen] = useState(false)
|
||||
const [paymentInvoice, setPaymentInvoice] = useState<InvoiceResponse | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
|
||||
|
||||
useEffect(() => {
|
||||
invoiceCreationModeApi.get().then(setMode).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
@@ -67,6 +78,10 @@ export function OrdersClient() {
|
||||
setViewOpen(true)
|
||||
}
|
||||
|
||||
function handleEdit(row: InvoiceResponse) {
|
||||
router.push(`/sales/invoices/${row.id}/edit`)
|
||||
}
|
||||
|
||||
function handlePrint(row: InvoiceResponse) {
|
||||
setSelectedInvoice(row)
|
||||
setActionMode('print')
|
||||
@@ -147,12 +162,24 @@ export function OrdersClient() {
|
||||
label: 'View invoice',
|
||||
onClick: handleView,
|
||||
},
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit draft items',
|
||||
onClick: handleEdit,
|
||||
visible: (target) => target.status === 'Draft',
|
||||
},
|
||||
{
|
||||
action: 'print',
|
||||
label: 'Mark as printed',
|
||||
label: 'Print invoice',
|
||||
onClick: handlePrint,
|
||||
visible: (target) => target.status === 'Draft',
|
||||
},
|
||||
{
|
||||
action: 'print',
|
||||
label: 'Reprint invoice',
|
||||
onClick: (target) => window.open(`/invoices/${target.id}/print`, '_blank'),
|
||||
visible: (target) => target.status !== 'Draft',
|
||||
},
|
||||
{
|
||||
action: 'finalize',
|
||||
label: 'Finalize invoice',
|
||||
@@ -178,6 +205,9 @@ export function OrdersClient() {
|
||||
Billing documents generated from confirmed Shop POs
|
||||
</p>
|
||||
</div>
|
||||
{mode === 'ModeB' && (
|
||||
<Button onClick={() => setCreateOpen(true)}>Create Invoice</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
@@ -212,6 +242,12 @@ export function OrdersClient() {
|
||||
invoices={paymentInvoice ? [paymentInvoice] : []}
|
||||
defaultInvoiceId={paymentInvoice?.id}
|
||||
/>
|
||||
|
||||
<CreateInvoiceDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InvoiceTemplate, type InvoicePaper } from '@/components/invoice/invoice-template'
|
||||
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
|
||||
|
||||
// Browser page rule per paper size. Receipts are one continuous strip (auto height). Margin is 0 so the
|
||||
// browser has no margin area to print its own header/footer (page title, URL, date) into; the sheet pads itself.
|
||||
const PAGE_RULE: Record<InvoicePaper, string> = {
|
||||
A4: '@page { size: A4; margin: 0; }',
|
||||
'80mm': '@page { size: 80mm auto; margin: 0; }',
|
||||
'58mm': '@page { size: 58mm auto; margin: 0; }',
|
||||
}
|
||||
|
||||
// Standalone printable invoice (no dashboard chrome). Opened in a new tab after "Print".
|
||||
export default function InvoicePrintPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const { invoice, payments, customer, error } = useInvoiceDocument(params.id)
|
||||
const [paper, setPaper] = useState<InvoicePaper>('A4')
|
||||
|
||||
// Open the browser print dialog once, when the invoice first appears.
|
||||
useEffect(() => {
|
||||
if (!invoice) return
|
||||
const t = setTimeout(() => window.print(), 600)
|
||||
return () => clearTimeout(t)
|
||||
}, [invoice])
|
||||
|
||||
if (error) return <p className="p-8 text-sm text-destructive">Failed to load invoice: {error}</p>
|
||||
if (!invoice) return <p className="p-8 text-sm text-muted-foreground">Loading invoice…</p>
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-6 print:max-w-none print:p-0">
|
||||
<style>{`${PAGE_RULE[paper]} @media print { html, body { background: #fff !important; } }`}</style>
|
||||
|
||||
<div className="mb-4 flex items-center justify-end gap-2 print:hidden">
|
||||
<div className="w-32">
|
||||
<Select value={paper} onValueChange={(v) => setPaper((v as InvoicePaper) ?? 'A4')}>
|
||||
<SelectTrigger><SelectValue placeholder="Paper size" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="A4">A4</SelectItem>
|
||||
<SelectItem value="80mm">80mm receipt</SelectItem>
|
||||
<SelectItem value="58mm">58mm receipt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => window.close()}>Close</Button>
|
||||
<Button type="button" onClick={() => window.print()}>Print</Button>
|
||||
</div>
|
||||
|
||||
<InvoiceTemplate
|
||||
invoice={invoice}
|
||||
payments={payments}
|
||||
customer={customer}
|
||||
paper={paper}
|
||||
showAgentEdits={invoice.status !== 'Finalized'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Shared number/date formatting for printed invoices.
|
||||
|
||||
/** `Rs. 12,450.00` — thousands separator, exactly 2 decimals. */
|
||||
export const fmtMoney = (n: number) =>
|
||||
`Rs. ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
|
||||
/** Quantity: up to 3 decimals, trailing zeros dropped. */
|
||||
export const fmtQty = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 3 })
|
||||
|
||||
/** `dd/MM/yyyy` */
|
||||
export function fmtDate(v: string | null | undefined) {
|
||||
if (!v) return '—'
|
||||
const d = new Date(v)
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${dd}/${mm}/${d.getFullYear()}`
|
||||
}
|
||||
|
||||
export const invoiceNo = (id: string) => `INV-${id.substring(0, 8).toUpperCase()}`
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { InvoiceItemResponse, InvoiceResponse, PaymentResponse } from '@/types/api'
|
||||
import { fmtDate, fmtMoney, fmtQty, invoiceNo } from './invoice-format'
|
||||
|
||||
export type InvoicePaper = 'A4' | '80mm' | '58mm'
|
||||
|
||||
export interface InvoiceCustomer {
|
||||
address?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
interface InvoiceTemplateProps {
|
||||
invoice: InvoiceResponse
|
||||
payments?: PaymentResponse[]
|
||||
customer?: InvoiceCustomer
|
||||
paper?: InvoicePaper
|
||||
// Draft-editor hooks (A4 only): inputs in the qty / price cells, a per-row action, and an extra table row.
|
||||
renderQuantity?: (item: InvoiceItemResponse) => ReactNode
|
||||
renderUnitPrice?: (item: InvoiceItemResponse) => ReactNode
|
||||
renderActions?: (item: InvoiceItemResponse) => ReactNode
|
||||
footerRow?: ReactNode
|
||||
// Blank lines for the agent's handwritten edits on the paper copy.
|
||||
showAgentEdits?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Soft slate palette: light rules, muted section headings.
|
||||
const RULE = '#cbd5e1'
|
||||
const HEADING = '#64748b'
|
||||
|
||||
const statusLabel = (invoice: InvoiceResponse) =>
|
||||
invoice.status === 'Finalized' ? null : invoice.status.toUpperCase()
|
||||
|
||||
// Removed lines stay on the document (struck through) but are excluded from every total.
|
||||
function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) {
|
||||
const total = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
||||
const paid = payments.reduce((s, p) => s + p.amount, 0)
|
||||
return { total, paid, balance: total - paid }
|
||||
}
|
||||
|
||||
export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) {
|
||||
return paper === 'A4' ? <A4Sheet {...props} /> : <ReceiptSheet {...props} paper={paper} />
|
||||
}
|
||||
|
||||
// ─── A4 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function A4Sheet({
|
||||
invoice,
|
||||
payments = [],
|
||||
customer,
|
||||
renderQuantity,
|
||||
renderUnitPrice,
|
||||
renderActions,
|
||||
footerRow,
|
||||
showAgentEdits,
|
||||
className,
|
||||
}: Omit<InvoiceTemplateProps, 'paper'>) {
|
||||
const { total, paid, balance } = totals(invoice, payments)
|
||||
const status = statusLabel(invoice)
|
||||
const hasRemoved = invoice.items.some((i) => i.isRemoved)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'invoice-sheet relative mx-auto w-full max-w-[210mm] rounded-lg border bg-white p-10 text-sm leading-relaxed text-neutral-900 shadow-sm',
|
||||
'print:max-w-none print:rounded-none print:border-0 print:p-[14mm] print:shadow-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-6 border-b pb-5" style={{ borderColor: RULE }}>
|
||||
<h2 className="text-4xl font-bold tracking-wide">INVOICE</h2>
|
||||
<dl className="grid grid-cols-[auto_auto] gap-x-6 gap-y-1 text-right text-sm">
|
||||
<dt className="text-neutral-500">Invoice No.</dt>
|
||||
<dd className="font-mono font-semibold">{invoiceNo(invoice.id)}</dd>
|
||||
<dt className="text-neutral-500">Invoice Date</dt>
|
||||
<dd>{fmtDate(invoice.createdAt)}</dd>
|
||||
<dt className="text-neutral-500">PO Ref.</dt>
|
||||
<dd className="font-mono">{invoice.shopPOId.substring(0, 8).toUpperCase()}</dd>
|
||||
{status && (
|
||||
<>
|
||||
<dt className="text-neutral-500">Status</dt>
|
||||
<dd className="font-semibold" style={{ color: HEADING }}>{status}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Bill to */}
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||
Bill To
|
||||
</h3>
|
||||
<p className="text-base font-semibold">{invoice.storeName}</p>
|
||||
{customer?.address && <p className="text-neutral-600">{customer.address}</p>}
|
||||
{customer?.phone && <p className="text-neutral-600">{customer.phone}</p>}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-2 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||
Item Details
|
||||
</h3>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="px-2 py-2 text-left font-medium">Item</th>
|
||||
<th className="w-24 px-2 py-2 text-right font-medium">Qty</th>
|
||||
<th className="w-36 px-2 py-2 text-right font-medium">Unit Price</th>
|
||||
<th className="w-40 px-2 py-2 text-right font-medium">Amount</th>
|
||||
{renderActions && <th className="w-24 px-2 py-2 print:hidden" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.items.length === 0 && !footerRow ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
|
||||
</tr>
|
||||
) : (
|
||||
invoice.items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
|
||||
>
|
||||
<td className="px-2 py-2.5">{item.itemName}</td>
|
||||
<td className="px-2 py-2.5 text-right">
|
||||
{renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-right">
|
||||
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-right font-medium">{fmtMoney(item.quantity * item.unitPrice)}</td>
|
||||
{renderActions && <td className="px-2 py-2.5 text-right no-underline print:hidden">{renderActions(item)}</td>}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
{footerRow}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasRemoved && (
|
||||
<p className="mt-2 text-xs text-neutral-500">Struck-through lines were removed and are not charged.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes + totals */}
|
||||
<div className="mt-6 grid grid-cols-[1fr_auto] gap-10">
|
||||
<div>
|
||||
{showAgentEdits && (
|
||||
<>
|
||||
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||
Notes / Agent Edits
|
||||
</h3>
|
||||
{[0, 1, 2, 3].map((n) => (
|
||||
<div key={n} className="h-7 border-b border-dotted border-neutral-400" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<dl className="w-72 break-inside-avoid text-sm">
|
||||
<div className="flex justify-between border-b py-1.5">
|
||||
<dt className="text-neutral-500">Subtotal</dt>
|
||||
<dd>{fmtMoney(total)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
|
||||
<dt>Total</dt>
|
||||
<dd>{fmtMoney(total)}</dd>
|
||||
</div>
|
||||
{paid > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between py-1.5">
|
||||
<dt className="text-neutral-500">Paid</dt>
|
||||
<dd>{fmtMoney(paid)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-t py-1.5 font-semibold">
|
||||
<dt>Balance Due</dt>
|
||||
<dd>{fmtMoney(balance)}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Payments */}
|
||||
<div className="mt-6 break-inside-avoid">
|
||||
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
|
||||
Payments
|
||||
</h3>
|
||||
{payments.length === 0 ? (
|
||||
<p className="text-neutral-500">No payment recorded yet.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b border-neutral-100">
|
||||
<td className="w-28 py-1">{fmtDate(p.collectedAt)}</td>
|
||||
<td className="py-1">
|
||||
{p.method}
|
||||
{p.chequeNumber && <span className="text-neutral-500"> · Cheque {p.chequeNumber}</span>}
|
||||
</td>
|
||||
<td className="py-1 text-right font-medium">{fmtMoney(p.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signatures */}
|
||||
<div className="mt-14 grid grid-cols-2 gap-16 text-xs text-neutral-600">
|
||||
<div className="border-t border-neutral-400 pt-1">Authorised by</div>
|
||||
<div className="border-t border-neutral-400 pt-1">Received by (customer signature)</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 80mm / 58mm thermal receipt ──────────────────────────────────────────────
|
||||
|
||||
function ReceiptSheet({
|
||||
invoice,
|
||||
payments = [],
|
||||
customer,
|
||||
paper,
|
||||
showAgentEdits,
|
||||
className,
|
||||
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) {
|
||||
const { total, paid, balance } = totals(invoice, payments)
|
||||
const status = statusLabel(invoice)
|
||||
const narrow = paper === '58mm'
|
||||
const rule = <div className="my-2 border-t border-dashed border-black" />
|
||||
const num = (n: number) => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'invoice-sheet mx-auto bg-white font-mono text-black shadow-sm print:shadow-none',
|
||||
narrow ? 'w-[58mm] px-[4mm] py-[2.5mm] text-[9.5px]' : 'w-[80mm] px-[5mm] py-[4mm] text-[11px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold tracking-widest">INVOICE</p>
|
||||
{status && <p className="font-bold">*** {status} ***</p>}
|
||||
</div>
|
||||
{rule}
|
||||
<div className="space-y-0.5">
|
||||
<p>No: {invoiceNo(invoice.id)}</p>
|
||||
<p>Date: {fmtDate(invoice.createdAt)}</p>
|
||||
<p>Customer: {invoice.storeName}</p>
|
||||
{customer?.phone && <p>Phone: {customer.phone}</p>}
|
||||
</div>
|
||||
{rule}
|
||||
<div className="space-y-1.5">
|
||||
{invoice.items.map((i) => (
|
||||
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
|
||||
<p>{i.itemName}</p>
|
||||
<p className="flex justify-between">
|
||||
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
|
||||
<span>{num(i.quantity * i.unitPrice)}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rule}
|
||||
<p className="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>{fmtMoney(total)}</span>
|
||||
</p>
|
||||
<div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
|
||||
<p className="flex justify-between">
|
||||
<span>TOTAL</span>
|
||||
<span>{fmtMoney(total)}</span>
|
||||
</p>
|
||||
</div>
|
||||
{paid > 0 && (
|
||||
<>
|
||||
<p className="flex justify-between"><span>Paid</span><span>{fmtMoney(paid)}</span></p>
|
||||
<p className="flex justify-between font-bold"><span>Balance</span><span>{fmtMoney(balance)}</span></p>
|
||||
</>
|
||||
)}
|
||||
{payments.length > 0 && (
|
||||
<>
|
||||
{rule}
|
||||
{payments.map((p) => (
|
||||
<p key={p.id} className="flex justify-between">
|
||||
<span>{fmtDate(p.collectedAt)} {p.method}</span>
|
||||
<span>{num(p.amount)}</span>
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{showAgentEdits && (
|
||||
<>
|
||||
{rule}
|
||||
<p>Notes:</p>
|
||||
{[0, 1, 2].map((n) => <div key={n} className="h-5 border-b border-dotted border-black" />)}
|
||||
</>
|
||||
)}
|
||||
{rule}
|
||||
<p className="text-center">Thank you!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { invoices as invoicesApi, payments as paymentsApi, shopPos as shopPosApi, getApiError } from '@/lib/api/index'
|
||||
import type { PaymentResponse, InvoiceResponse } from '@/types/api'
|
||||
import type { InvoiceCustomer } from './invoice-template'
|
||||
|
||||
// Loads everything the printed invoice needs. Only the invoice is required — if payments or the
|
||||
// customer details fail to load, that block is simply left out.
|
||||
export function useInvoiceDocument(invoiceId: string | null | undefined, enabled = true) {
|
||||
const [invoice, setInvoice] = useState<InvoiceResponse | null>(null)
|
||||
const [payments, setPayments] = useState<PaymentResponse[]>([])
|
||||
const [customer, setCustomer] = useState<InvoiceCustomer | undefined>()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !invoiceId) return
|
||||
let cancelled = false
|
||||
setInvoice(null)
|
||||
setPayments([])
|
||||
setCustomer(undefined)
|
||||
setError(null)
|
||||
|
||||
invoicesApi.get(invoiceId)
|
||||
.then((inv) => {
|
||||
if (cancelled) return
|
||||
setInvoice(inv)
|
||||
paymentsApi.listByInvoice(inv.id).then((p) => !cancelled && setPayments(p)).catch(() => {})
|
||||
shopPosApi.get(inv.shopPOId)
|
||||
.then((po) => !cancelled && setCustomer({ address: po.storeAddress, phone: po.storeContactNumber }))
|
||||
.catch(() => {})
|
||||
})
|
||||
.catch((err) => !cancelled && setError(getApiError(err)))
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [invoiceId, enabled])
|
||||
|
||||
return { invoice, payments, customer, error }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useNotifications } from '@/contexts/notification-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
|
||||
|
||||
const SEVERITY_DOT: Record<NotificationSeverity, string> = {
|
||||
Info: 'bg-sky-500',
|
||||
Success: 'bg-emerald-500',
|
||||
Warning: 'bg-amber-500',
|
||||
Critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
export function timeAgo(iso: string): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
export function NotificationRow({
|
||||
notification: n,
|
||||
onOpen,
|
||||
}: {
|
||||
notification: NotificationResponse
|
||||
onOpen: (n: NotificationResponse) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(n)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
|
||||
!n.isRead && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', n.isRead ? 'bg-transparent' : SEVERITY_DOT[n.severity])} />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className={cn('truncate text-sm', n.isRead ? 'text-foreground' : 'font-medium text-foreground')}>
|
||||
{n.title}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{n.message}</span>
|
||||
<span className="text-[11px] text-muted-foreground/70">{timeAgo(n.createdAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const { recent, unreadCount, open, markAllRead } = useNotifications()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-none text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium">Notifications</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={unreadCount === 0}
|
||||
onClick={() => { void markAllRead() }}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 divide-y divide-border overflow-y-auto">
|
||||
{recent.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">You're all caught up.</p>
|
||||
) : (
|
||||
recent.map(n => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onOpen={item => { setIsOpen(false); void open(item) }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-2">
|
||||
<Link
|
||||
href="/notifications"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block rounded-xl px-3 py-2 text-center text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
align = "end",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 w-[min(24rem,calc(100vw-2rem))] origin-(--transform-origin) rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -134,7 +134,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ title: 'Bank Accounts', href: '/payments/bank-accounts', icon: BankIcon, description: 'Cash and bank accounts used for payments and deposits' },
|
||||
{ title: 'Cheque Books', href: '/payments/cheque-books', icon: ClipboardListIcon, description: 'Cheque books issued from the company’s own supply' },
|
||||
{ title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' },
|
||||
{ title: 'GL Integration', href: '/configuration/general-ledger', icon: Settings01Icon, description: 'Set up the General Ledger link and retry failed postings', permission: 'gl.manage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -180,7 +179,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
|
||||
{ title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' },
|
||||
{ title: 'Financial Reports', href: '/reports/finance', icon: DollarSquareIcon, description: 'Trial balance, ledger, profit & loss, balance sheet and cash flow from the General Ledger', permission: 'finance-reports.view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -107,14 +107,6 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
'roles.view',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
label: 'Finance',
|
||||
codes: [
|
||||
'finance-reports.view',
|
||||
'gl.manage',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'System',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { notifications, NOTIFICATION_HUB_URL } from '@/lib/api/notifications'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
|
||||
|
||||
const RECENT_COUNT = 20
|
||||
|
||||
const TOAST_TYPE: Record<NotificationSeverity, 'info' | 'success' | 'warning' | 'error'> = {
|
||||
Info: 'info',
|
||||
Success: 'success',
|
||||
Warning: 'warning',
|
||||
Critical: 'error',
|
||||
}
|
||||
|
||||
interface NotificationContextValue {
|
||||
/** The most recent notifications, newest first (what the bell popover shows). */
|
||||
recent: NotificationResponse[]
|
||||
unreadCount: number
|
||||
/** Marks the notification read (if needed) and navigates to its link. */
|
||||
open: (n: NotificationResponse) => Promise<void>
|
||||
markAllRead: () => Promise<void>
|
||||
/** Re-fetches the recent list and the unread count. */
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null)
|
||||
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const { user } = useAuth()
|
||||
const [recent, setRecent] = useState<NotificationResponse[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [page, count] = await Promise.all([
|
||||
notifications.list({ page: 1, pageSize: RECENT_COUNT }),
|
||||
notifications.unreadCount(),
|
||||
])
|
||||
setRecent(page.items)
|
||||
setUnreadCount(count)
|
||||
} catch {
|
||||
// The bell is a convenience; never break the page if it can't load.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const markRead = useCallback(async (n: NotificationResponse) => {
|
||||
if (n.isRead) return
|
||||
setRecent(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
|
||||
setUnreadCount(c => Math.max(0, c - 1))
|
||||
try {
|
||||
await notifications.markRead(n.id)
|
||||
} catch {
|
||||
await refresh()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const open = useCallback(async (n: NotificationResponse) => {
|
||||
await markRead(n)
|
||||
if (n.link) router.push(n.link)
|
||||
}, [markRead, router])
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
setRecent(prev => prev.map(x => ({ ...x, isRead: true })))
|
||||
setUnreadCount(0)
|
||||
try {
|
||||
await notifications.markAllRead()
|
||||
} catch {
|
||||
await refresh()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
// Keep the latest `open` for the long-lived socket handler without reconnecting.
|
||||
const openRef = useRef(open)
|
||||
useEffect(() => { openRef.current = open }, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
void refresh()
|
||||
|
||||
const connection = new HubConnectionBuilder()
|
||||
.withUrl(NOTIFICATION_HUB_URL, { withCredentials: true })
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(LogLevel.Warning)
|
||||
.build()
|
||||
|
||||
connection.on('notification', (n: NotificationResponse) => {
|
||||
setRecent(prev => [n, ...prev.filter(x => x.id !== n.id)].slice(0, RECENT_COUNT))
|
||||
setUnreadCount(c => c + 1)
|
||||
toast.add({
|
||||
title: n.title,
|
||||
description: n.message,
|
||||
type: TOAST_TYPE[n.severity] ?? 'info',
|
||||
actionProps: n.link
|
||||
? { children: 'Open', onClick: () => { void openRef.current(n) } }
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// Anything pushed while the socket was down is only in the inbox — catch up.
|
||||
connection.onreconnected(() => { void refresh() })
|
||||
|
||||
connection.start().catch(() => {
|
||||
// Initial connect failed (offline / API down); the inbox endpoints still work.
|
||||
})
|
||||
|
||||
return () => { void connection.stop() }
|
||||
}, [user, refresh])
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider
|
||||
value={{
|
||||
recent: user ? recent : [],
|
||||
unreadCount: user ? unreadCount : 0,
|
||||
open,
|
||||
markAllRead,
|
||||
refresh,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNotifications(): NotificationContextValue {
|
||||
const ctx = useContext(NotificationContext)
|
||||
if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider')
|
||||
return ctx
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user