Compare commits

..

9 Commits

Author SHA1 Message Date
HarithaRandunu ca81e9bc42 feat: add General Ledger integration and financial reporting features
- Implemented General Ledger client component for managing GL integration.
- Created a dedicated page for General Ledger configuration.
- Developed Finance Report client component for generating various financial reports.
- Added report blocks for structured display of financial data.
- Introduced API endpoints for fetching GL accounts and financial reports.
- Updated navigation to include links for GL integration and financial reports.
- Enhanced permissions to manage GL and view financial reports.
- Updated appsettings to include General Ledger API configuration.
2026-10-20 18:54:30 +05:30
kalana_Thilakarathna cc33ecdd54 Merge pull request 'Implement store credit tracking and enforcement' (#77) from dev into production
Reviewed-on: #77
2026-10-20 11:51:10 +05:30
kalana_Thilakarathna f77db46a02 Merge pull request 'Dev' (#76) from dev into production
Reviewed-on: #76
2026-10-19 23:38:07 +05:30
kalana_Thilakarathna 267144f7a1 Merge pull request 'feat: add EmptyState component for improved no-data display in supplier payments' (#75) from dev into production
Reviewed-on: #75
2026-10-19 22:35:48 +05:30
kalana_Thilakarathna efb5657b6b Merge pull request 'feat: update row actions to include payment functionality and add corresponding icon' (#74) from dev into production
Reviewed-on: #74
2026-10-19 22:13:07 +05:30
kalana_Thilakarathna 622b75e187 Merge pull request 'feat: integrate payment functionality with new payment sheet and enhance supplier payments UI' (#73) from dev into production
Reviewed-on: #73
2026-10-19 22:02:45 +05:30
kalana_Thilakarathna 293df41d4e Merge pull request 'Dev' (#72) from dev into production
Reviewed-on: #72
2026-10-19 21:41:42 +05:30
DeepnaPooja 5e81fd77b1 Merge pull request 'Dev' (#71) from dev into production
Reviewed-on: #71
2026-10-19 16:47:06 +05:30
kalana_Thilakarathna a1fd5f1a08 Merge pull request 'feat: add cheque books and received cheques management' (#69) from dev into production
Reviewed-on: #69
2026-10-19 16:12:32 +05:30
129 changed files with 3736 additions and 31633 deletions
@@ -1,209 +0,0 @@
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);
}
}
+11 -77
View File
@@ -22,9 +22,6 @@ 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; }
@@ -69,13 +66,12 @@ 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)
@@ -178,54 +174,6 @@ 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)
@@ -635,6 +583,16 @@ 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>()
@@ -761,30 +719,6 @@ 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,6 +68,8 @@ 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)
{
@@ -134,7 +136,9 @@ public static class RoleAndPermissionSeed
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermFinanceReportView, Code = "finance-reports.view", Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGlManage, Code = "gl.manage", Description = "Set up the General Ledger link and retry failed postings", CreatedAt = SeedDate, IsActive = true }
);
// SuperAdmin gets every permission
@@ -191,7 +195,9 @@ public static class RoleAndPermissionSeed
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermFinanceReportView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGlManage, CreatedAt = SeedDate, IsActive = true }
);
}
}
@@ -1,4 +1,5 @@
using Distribution.Features.BankAccount;
using Distribution.Features.GeneralLedger;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -7,7 +8,8 @@ namespace Distribution.Features.ChequeBook;
public class ChequeBookService(
IChequeBookRepository repo,
IBankAccountRepository bankAccountRepo,
IUnitOfWork uow)
IUnitOfWork uow,
GlPostingService gl)
{
private const string DefaultCurrency = "LKR";
@@ -171,6 +173,7 @@ public class ChequeBookService(
repo.UpdatePage(page);
await uow.CommitAsync(ct);
await gl.PostIssuedChequeAsync(page, request.Action, ct);
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
}
}
@@ -2,27 +2,21 @@ namespace Distribution.Features.DashboardReport;
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
public record SalesKpis(
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
decimal GrossProfit, decimal? GrossProfitMarginPercent);
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
public record TrendPoint(string Period, decimal Sales, decimal Returns);
public record NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse(
DateTime From, DateTime To, string Granularity,
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
SalesKpiComparison Kpis, List<TrendPoint> Trend,
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
public record PeriodAmount(DateTime Date, decimal Amount);
@@ -19,30 +19,29 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
public Guid RepId { get; set; }
}
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
// 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).
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q =
from li in db.InvoiceItems.AsNoTracking()
where li.IsActive && !li.IsRemoved
&& li.Invoice.Status == InvoiceStatus.Finalized
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
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
select new SoldLine
{
Date = li.Invoice.FinalizedAt!.Value.Date,
PoId = li.Invoice.FK_ShopPOId,
Value = li.Quantity * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
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,
CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name,
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
RepId = li.Invoice.ShopPO.RaisedById,
RegionId = li.ShopPO.Store.Route.FK_RegionId,
RegionName = li.ShopPO.Store.Route.Region.Name,
RepId = li.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);
@@ -53,10 +52,8 @@ 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
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
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;
@@ -71,59 +68,7 @@ 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, 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));
return new SalesTotals(gross, returns, orders, costedSales, cost);
}
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 = 92; // covers a "last 3 months" daily trend
private const int DailyGranularityMaxDays = 62;
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
{
@@ -25,18 +25,14 @@ public class DashboardReportService(IDashboardReportRepository repo)
private static SalesKpis ToKpis(SalesTotals t)
{
var net = t.GrossSales - t.ReturnValue - t.Discounts;
var net = t.GrossSales - t.ReturnValue;
var gp = t.CostedSales - t.CostOfSales;
return new SalesKpis(
t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
t.GrossSales, t.ReturnValue, 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;
@@ -45,8 +41,6 @@ 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);
@@ -61,9 +55,6 @@ 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,7 +5,6 @@ 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,5 +1,4 @@
using Distribution.Features.LoadingEvent;
using Distribution.Features.Notifications;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
@@ -14,18 +13,8 @@ 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,
@@ -192,13 +181,6 @@ 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!);
}
@@ -222,13 +204,6 @@ public class DeliveryTaskService(
}
catch { await uow.RollbackAsync(ct); throw; }
if (task.ShopPO?.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskFailed,
DeliveryRecipients(task.ShopPO.FK_StoreId),
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
ct);
return ToResponse(task);
}
}
@@ -0,0 +1,74 @@
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");
}
}
@@ -0,0 +1,48 @@
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);
}
}
@@ -0,0 +1,100 @@
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) });
}
@@ -0,0 +1,33 @@
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;
}
@@ -0,0 +1,34 @@
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);
}
@@ -0,0 +1,449 @@
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&amp;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 };
}
@@ -0,0 +1,226 @@
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;
}
}
+4 -1
View File
@@ -1,3 +1,4 @@
using Distribution.Features.GeneralLedger;
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
@@ -13,7 +14,8 @@ public class GrnService(
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
Distribution.Features.SysConfig.SysConfigService sysConfig,
GlPostingService gl)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{
@@ -482,6 +484,7 @@ 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,12 +9,6 @@ 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,52 +36,6 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/preview-totals")]
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
{
var result = await service.PreviewTotalsAsync(id, request);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/versions")]
public async Task<IActionResult> GetVersions(Guid id)
{
var result = await service.GetVersionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.EditedByUserId;
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
try
{
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
+4 -43
View File
@@ -1,48 +1,12 @@
using Distribution.Features.ShopPO;
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
// Quantity = how many free units the scheme still entitles for the lines as typed.
public record FreeEntitlement(Guid SchemeId, int Quantity);
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
public record InvoiceVersionResponse(
Guid Id,
int VersionNumber,
InvoiceVersionStage Stage,
decimal TotalAmount,
Guid CreatedBy,
DateTime CreatedAt,
List<InvoiceVersionItemResponse> Items);
public record InvoiceItemResponse(
Guid Id,
Guid? ShopPOItemId,
Guid ItemId,
string ItemName,
int? OrderedQuantity,
int Quantity,
decimal UnitPrice,
bool IsRemoved,
bool IsFree = false,
Guid? SchemeId = null,
string? SchemeName = null);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
@@ -56,11 +20,8 @@ public record InvoiceResponse(
DateTime? FinalizedAt,
Guid? FinalizedBy,
decimal TotalAmount,
decimal DiscountAmount,
decimal PaidAmount,
bool IsActive,
DateTime CreatedAt,
List<InvoiceItemResponse> Items,
List<ShopPODiscountResponse>? Discounts = null);
DateTime CreatedAt);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -10,8 +10,6 @@ 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);
@@ -51,35 +49,9 @@ 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);
}
+24 -264
View File
@@ -1,5 +1,4 @@
using Distribution.Features.Scheme;
using Distribution.Features.ShopPO;
using Distribution.Features.GeneralLedger;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
@@ -9,10 +8,9 @@ namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IShopPORepository shopPoRepo,
SchemeService schemeService,
IUnitOfWork uow,
ICurrentUserService currentUser)
ICurrentUserService currentUser,
GlPostingService gl)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId,
@@ -21,114 +19,14 @@ public class InvoiceService(
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.DiscountAmount,
i.TotalAmount,
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
i.IsActive, i.CreatedAt,
i.Items.Where(x => x.IsActive).Select(x =>
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
// scheme rules ignore them.
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved)
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
.ToList();
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
// and at print/finalize. The redemption amount is updated with it.
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
{
var lines = items.ToList();
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
var billable = BillableTotal(lines);
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
invoice.DiscountAmount = Math.Min(discount, billable);
invoice.TotalAmount = billable - invoice.DiscountAmount;
}
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null;
var lines = request.Items.Where(l => !l.IsRemoved)
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
.ToList();
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
var chosen = request.DiscountSchemeIds is null
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
var discount = Math.Min(chosen, billable);
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
}
// Immutable copy of the invoice lines as they stand right now.
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
new()
{
FK_InvoiceId = invoice.Id,
VersionNumber = versionNumber,
Stage = stage,
TotalAmount = invoice.TotalAmount,
CreatedBy = userId,
Items = items.Select(i => new InvoiceVersionItem
{
FK_ShopPOItemId = i.FK_ShopPOItemId,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
IsRemoved = i.IsRemoved
}).ToList()
};
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
{
if (await repo.GetByIdAsync(id) is null) return null;
var versions = await repo.GetVersionsAsync(id);
return versions.Select(v => new InvoiceVersionResponse(
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
v.Items.Select(i => new InvoiceVersionItemResponse(
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
}
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
{
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
// Added lines may or may not be in invoice.Items yet, so merge by reference.
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
}
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
{
FK_InvoiceId = invoiceId,
FK_ShopPOItemId = i.Id,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
IsFree = i.IsFree,
FK_SchemeId = i.FK_SchemeId,
SchemeName = i.SchemeName
}).ToList();
i.IsActive, i.CreatedAt);
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null;
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
return invoice is null ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
@@ -146,131 +44,6 @@ public class InvoiceService(
return new OutstandingPaymentsResponse(count, outstanding);
}
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
{
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.ConfirmedAt is null)
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
if (po.InvoiceId is not null)
throw new InvalidOperationException("An invoice already exists for this Shop PO");
var invoice = new Models.Invoice
{
FK_ShopPOId = po.Id,
Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending,
CreatedBy = createdBy
};
var lines = BuildItemsFromShopPO(po, invoice.Id);
await ApplyTotalsAsync(invoice, lines);
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(invoice);
await repo.AddItemsAsync(lines);
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
po.InvoiceId = invoice.Id;
shopPoRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(invoice.Id);
return ToResponse(saved!);
}
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
var newLines = new List<InvoiceItem>();
foreach (var edit in request.Items)
{
if (edit.UnitPrice is < 0)
throw new InvalidOperationException("Unit price cannot be negative");
if (edit.InvoiceItemId is Guid lineId)
{
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
item.IsRemoved = edit.IsRemoved;
if (!edit.IsRemoved)
{
ValidateQuantity(item, edit.Quantity);
item.Quantity = edit.Quantity;
}
if (edit.UnitPrice is decimal price)
{
if (item.IsFree && price != 0m)
throw new InvalidOperationException("A free-issue line must stay at zero price");
item.UnitPrice = price;
}
}
else if (edit.ItemId is Guid itemId)
{
// New line for an item that was not on the PO.
if (edit.IsRemoved) continue;
if (edit.Quantity <= 0)
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
if (edit.UnitPrice is null)
throw new InvalidOperationException("Unit price is required for an added item");
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
var catalogItem = await repo.GetItemAsync(itemId)
?? throw new InvalidOperationException($"Item {itemId} not found");
newLines.Add(new InvoiceItem
{
FK_InvoiceId = invoice.Id,
FK_ItemId = catalogItem.Id,
Item = catalogItem,
Quantity = edit.Quantity,
UnitPrice = edit.UnitPrice.Value
});
}
else
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
}
var allLines = invoice.Items.Concat(newLines).ToList();
await schemeService.ApplyInvoiceDiscountSelectionAsync(
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
await ApplyTotalsAsync(invoice, allLines);
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!;
}
private static void ValidateQuantity(InvoiceItem item, int quantity)
{
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
if (quantity < 0 || quantity > ordered)
throw new InvalidOperationException(
$"Quantity for item {item.Id} must be between 0 and {ordered}");
}
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
@@ -279,21 +52,12 @@ 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 AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
await uow.CommitAsync(ct);
}
try { repo.Update(invoice); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
@@ -307,39 +71,35 @@ 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 ?? [];
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
foreach (var adjustment in adjustments)
if (adjustments.Count > 0)
{
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 poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
decimal total = 0;
foreach (var item in poItems)
{
ValidateQuantity(item, adjustment.AdjustedQuantity);
item.Quantity = adjustment.AdjustedQuantity;
}
}
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
if (items.Count > 0)
await ApplyTotalsAsync(invoice, items);
if (billedQuantity < 0 || billedQuantity > item.Quantity)
throw new InvalidOperationException(
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
total += billedQuantity * item.UnitPrice;
}
invoice.TotalAmount = total;
}
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedBy = finalizedBy;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
await uow.CommitAsync(ct);
}
try { repo.Update(invoice); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
await gl.PostInvoiceFinalizedAsync(invoice, ct);
return ToResponse(invoice);
}
}
@@ -1,29 +0,0 @@
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);
}
@@ -1,63 +0,0 @@
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);
}
}
@@ -1,57 +0,0 @@
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 }); }
}
}
@@ -1,28 +0,0 @@
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);
@@ -1,22 +0,0 @@
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;
}
@@ -1,53 +0,0 @@
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}.");
}
}
}
@@ -1,156 +0,0 @@
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);
}
@@ -1,162 +0,0 @@
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)] + "…";
}
@@ -1,49 +0,0 @@
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."),
};
}
@@ -1,37 +0,0 @@
namespace Distribution.Features.Notifications;
public enum ScopeKind { Region, Route, Store }
/// <summary>
/// Who a notification goes to. There is deliberately no "everyone" rule — every trigger must say who
/// needs to know. Scope coverage follows Region → Route → Store: a region scope covers its routes
/// and their stores, a route scope covers its stores.
/// </summary>
public abstract record RecipientRule
{
/// <summary>These specific users (creator, driver, approver, route owner …).</summary>
public static RecipientRule Users(params Guid[] ids) => new UsersRule(ids);
/// <summary>Every active user whose role holds the permission code.</summary>
public static RecipientRule Permission(string code) => new PermissionRule(code);
/// <summary>Every active user whose scope covers the region / route / store.</summary>
public static RecipientRule Scope(ScopeKind kind, Guid id) => new ScopeRule(kind, id);
/// <summary>Users who hold the permission AND whose scope covers the region / route / store.</summary>
public static RecipientRule PermissionInScope(string code, ScopeKind kind, Guid id) =>
new PermissionInScopeRule(code, kind, id);
/// <summary>The user's supervisor (User.FK_ReportsToUserId).</summary>
public static RecipientRule ReportsTo(Guid userId) => new ReportsToRule(userId);
/// <summary>The owner of the route (<see cref="ScopeKind.Route"/>) or of the store's route (<see cref="ScopeKind.Store"/>).</summary>
public static RecipientRule RouteOwner(ScopeKind kind, Guid id) => new RouteOwnerRule(kind, id);
}
public sealed record UsersRule(IReadOnlyCollection<Guid> Ids) : RecipientRule;
public sealed record PermissionRule(string Code) : RecipientRule;
public sealed record ScopeRule(ScopeKind Kind, Guid Id) : RecipientRule;
public sealed record PermissionInScopeRule(string Code, ScopeKind Kind, Guid Id) : RecipientRule;
public sealed record ReportsToRule(Guid UserId) : RecipientRule;
public sealed record RouteOwnerRule(ScopeKind Kind, Guid Id) : RecipientRule;
@@ -1,4 +1,5 @@
using Distribution.Features.BankAccount;
using Distribution.Features.GeneralLedger;
using Distribution.Features.Invoice;
using Distribution.Features.ReceivedCheque;
using Distribution.Features.StoreCredit;
@@ -15,7 +16,8 @@ public class PaymentService(
IReceivedChequeRepository receivedChequeRepo,
IStoreCreditMovementRepository creditRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
ICurrentUserService currentUser,
GlPostingService gl)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
@@ -150,14 +152,13 @@ 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 = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
{
var lines =
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))
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))
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.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 (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 (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
@@ -93,36 +93,33 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
.Select(g => new
{
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
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),
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),
});
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 orders and stores per item are counted separately: MySQL can't run the correlated
// Distinct stores per item is 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 pairs = await SoldLines(f)
var storePairs = await SoldLines(f)
.Where(l => ids.Contains(l.FK_ItemId))
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
.Distinct()
.ToListAsync();
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 storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
r.Orders, 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.Quantity * x.UnitPrice);
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
{
@@ -130,7 +127,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.Quantity) })
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
.ToDictionaryAsync(x => x.Key, x => x.Qty);
}
@@ -1,5 +1,6 @@
using Distribution.Features.BankAccount;
using Distribution.Features.ChequeBook;
using Distribution.Features.GeneralLedger;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -8,7 +9,8 @@ namespace Distribution.Features.ReceivedCheque;
public class ReceivedChequeService(
IReceivedChequeRepository repo,
IBankAccountRepository bankAccountRepo,
IUnitOfWork uow)
IUnitOfWork uow,
GlPostingService gl)
{
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
@@ -110,6 +112,7 @@ public class ReceivedChequeService(
repo.Update(cheque);
await uow.CommitAsync(ct);
await gl.PostReceivedChequeAsync(cheque, request.Action, ct);
return ToResponse(cheque);
}
}
+1 -51
View File
@@ -1,6 +1,4 @@
using Distribution.Features.DeliveryRide;
using Distribution.Features.Notifications;
using Distribution.Features.Stock;
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW;
@@ -12,14 +10,9 @@ 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);
@@ -79,12 +72,6 @@ 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!);
}
@@ -138,46 +125,9 @@ public class RideService(
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyRideCancelledAsync(ride, request, userId, ct);
return ToResponse(ride);
}
// A ride has no route of its own; the people to tell are the owners and managers of the routes
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
{
try
{
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
var routeIds = tasks
.Where(t => t.ShopPO?.Store is not null)
.Select(t => t.ShopPO.Store.FK_RouteId)
.Distinct()
.ToList();
var recipients = new List<RecipientRule>();
foreach (var routeId in routeIds)
{
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
}
if (recipients.Count == 0) return;
await notifications.NotifyAsync(
NotificationTypes.RideCancelled,
recipients,
new NotificationArgs(
new { CancelType = request.TypeCode, Reason = request.Reason },
"Ride", ride.Id, RidesLink, userId),
ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
}
}
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
{
var driver = await driverRepo.GetByIdAsync(driverProfileId)
@@ -10,10 +10,7 @@ public interface ISchemeRepository
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
Task AddRedemptionAsync(Models.SchemeRedemption entity);
Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId);
void RemoveRedemption(Models.SchemeRedemption entity);
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
@@ -112,14 +112,6 @@ public class SchemeController(SchemeService service) : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPost("suggest")]
[Authorize("scheme.view")]
public async Task<IActionResult> Suggest(SuggestSchemesRequest request)
{
var result = await service.SuggestAsync(request);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/spend")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetSpend(Guid id)
+1 -7
View File
@@ -94,13 +94,7 @@ public record SchemeEligibilityResponse(
string? Reason,
decimal? DiscountAmount,
Guid? FreeItemId,
decimal? FreeQty,
string? FreeItemName = null);
/// <summary>A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list.</summary>
public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false);
public record SuggestSchemesRequest(Guid StoreId, List<SuggestLine> Items);
decimal? FreeQty);
public record SchemeRedemptionResponse(
Guid Id,
@@ -49,25 +49,9 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(entity);
public async Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId)
{
var list = await context.SchemeRedemptions.Include(r => r.Scheme).Where(r => r.FK_ShopPOId == shopPOId).ToListAsync();
// Rows added earlier in this unit of work are not in the database yet; rows removed are still there.
foreach (var added in context.SchemeRedemptions.Local.Where(r => r.FK_ShopPOId == shopPOId && !list.Contains(r)))
list.Add(added);
return list.Where(r => context.Entry(r).State != EntityState.Deleted).ToList();
}
public void RemoveRedemption(Models.SchemeRedemption entity) =>
context.SchemeRedemptions.Remove(entity);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
+10 -363
View File
@@ -1,7 +1,5 @@
using System.Text.Json;
using Distribution.Features.Invoice;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Features.Store;
@@ -16,8 +14,6 @@ public class SchemeService(
IItemRepository itemRepo,
IStoreRepository storeRepo,
IStockRepository stockRepo,
IInvoiceRepository invoiceRepo,
PriceListService priceListService,
IUnitOfWork uow)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -265,7 +261,12 @@ public class SchemeService(
foreach (var scheme in candidates)
{
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
continue;
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
continue;
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
continue;
result.Add(EvaluateRule(scheme, items));
@@ -274,328 +275,6 @@ public class SchemeService(
return result;
}
private static bool IsInScope(Models.Scheme scheme, string? storeSegment, Guid? storeRegionId, DateTime asOf)
{
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != storeRegionId) return false;
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, storeSegment, StringComparison.OrdinalIgnoreCase)) return false;
return asOf >= scheme.DateRangeFrom && asOf.Date <= scheme.DateRangeTo.Date;
}
/// <summary>
/// Suggestions for a Shop PO that is still being built (nothing saved yet). Returns eligible schemes,
/// plus BuyXGetY/FreeBundle schemes the cart is part-way to, so the UI can nudge ("add 4 more").
/// </summary>
public async Task<List<SchemeEligibilityResponse>?> SuggestAsync(SuggestSchemesRequest request)
{
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
var lines = new List<ShopPOItem>();
foreach (var l in request.Items.Where(i => !i.IsFree))
{
var price = l.UnitPrice
?? (await priceListService.ResolveByStoreAsync(l.ItemId, request.StoreId, DateTime.Now))?.Price
?? 0m;
lines.Add(new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = price });
}
var regionId = store.Route?.FK_RegionId;
var inCart = lines.Select(l => l.FK_ItemId).ToHashSet();
var result = new List<SchemeEligibilityResponse>();
foreach (var scheme in (await repo.GetAllAsync()).Where(s => s.IsActive))
{
if (!IsInScope(scheme, store.Segment, regionId, DateTime.Now)) continue;
var eval = EvaluateRule(scheme, lines);
var relevant = eval.Eligible || scheme.RuleType switch
{
SchemeRuleType.BuyXGetY => scheme.FK_TriggerItemId.HasValue && inCart.Contains(scheme.FK_TriggerItemId.Value),
SchemeRuleType.FreeBundle => scheme.BundleComponents.Any(c => inCart.Contains(c.FK_ItemId)),
_ => false
};
if (!relevant) continue;
result.Add(eval with { FreeItemName = scheme.FreeItem?.Name });
}
return result;
}
/// <summary>
/// Checks the free lines of a Shop PO that is about to be saved: each must reference an in-scope scheme the
/// paid lines still qualify for, be the scheme's free item, stay within the entitled qty, and be in stock.
/// Stamps the scheme-name snapshot and forces UnitPrice to 0. Read-only; throws InvalidOperationException.
/// </summary>
public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines)
{
var freeLines = lines.Where(l => l.IsFree).ToList();
if (freeLines.Count == 0) return;
if (freeLines.Any(l => l.FK_SchemeId is null))
throw new InvalidOperationException("A free-issue line must reference the scheme that granted it.");
if (freeLines.Any(l => l.Quantity <= 0))
throw new InvalidOperationException("Free-issue quantity must be greater than zero (remove the line instead).");
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
?? throw new InvalidOperationException("Store not found.");
var regionId = store.Route?.FK_RegionId;
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
{
var scheme = await repo.GetByIdAsync(group.Key);
if (scheme is null || !scheme.IsActive)
throw new InvalidOperationException("A free-issue line references a scheme that no longer exists.");
if (!IsInScope(scheme, store.Segment, regionId, asOf))
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
var eval = EvaluateRule(scheme, lines);
if (!eval.Eligible || eval.FreeItemId is null || eval.FreeQty is null)
throw new InvalidOperationException(eval.Reason ?? $"Order no longer qualifies for scheme '{scheme.Name}'.");
if (group.Any(l => l.FK_ItemId != eval.FreeItemId))
throw new InvalidOperationException($"Scheme '{scheme.Name}' grants a different free item.");
var qty = group.Sum(l => l.Quantity);
if (qty > eval.FreeQty.Value)
throw new InvalidOperationException($"Free quantity {qty} exceeds the {eval.FreeQty.Value:0.##} entitled by scheme '{scheme.Name}'.");
var available = (await stockRepo.GetBalancesByItemAsync(eval.FreeItemId.Value)).Sum(b => b.Quantity);
if (available < qty)
throw new InvalidOperationException($"Insufficient stock to give {qty} free of scheme '{scheme.Name}'.");
foreach (var l in group)
{
l.UnitPrice = 0m;
l.SchemeName = scheme.Name;
}
}
}
/// <summary>
/// Checks the percentage-discount schemes (FlatPercent / SlabDiscount) the user accepted for a Shop PO that is
/// about to be saved: one at most, in scope, and the paid lines must still qualify. Read-only; throws.
/// </summary>
public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines, List<Guid>? schemeIds)
{
if (schemeIds is not { Count: > 0 }) return;
if (schemeIds.Distinct().Count() > 1)
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
?? throw new InvalidOperationException("Store not found.");
var scheme = await repo.GetByIdAsync(schemeIds[0]);
if (scheme is null || !scheme.IsActive)
throw new InvalidOperationException("The selected discount scheme no longer exists.");
if (scheme.RuleType is not (SchemeRuleType.FlatPercent or SchemeRuleType.SlabDiscount))
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not a discount scheme.");
if (!IsInScope(scheme, store.Segment, store.Route?.FK_RegionId, asOf))
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
var eval = EvaluateRule(scheme, lines);
if (!eval.Eligible)
throw new InvalidOperationException(eval.Reason ?? $"Order does not qualify for scheme '{scheme.Name}'.");
}
/// <summary>
/// Re-evaluates the percentage discounts redeemed against a Shop PO for the given (invoice) lines and returns
/// their total. With persist = true the redemption amounts are updated too (the caller commits), so spend/ROI and
/// the invoice stay in step; a discount the lines no longer qualify for drops to 0 and comes back if they requalify.
/// </summary>
public async Task<decimal> CalculateDiscountAsync(Guid shopPOId, IEnumerable<ShopPOItem> lines, bool persist)
{
var priced = lines.ToList();
decimal total = 0m;
foreach (var r in (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue))
{
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
var amount = eval is { Eligible: true, DiscountAmount: not null } ? eval.DiscountAmount.Value : 0m;
if (persist) r.DiscountAmount = amount;
total += amount;
}
return total;
}
// How many free units each scheme currently entitles for these paid lines (0 when no longer eligible).
private async Task<Dictionary<Guid, int>> FreeEntitlementsAsync(IEnumerable<Guid> schemeIds, List<ShopPOItem> paidLines)
{
var result = new Dictionary<Guid, int>();
foreach (var id in schemeIds.Distinct())
{
var scheme = await repo.GetByIdAsync(id);
var eval = scheme is null ? null : EvaluateRule(scheme, paidLines);
result[id] = eval is { Eligible: true, FreeQty: not null } ? (int)Math.Floor(eval.FreeQty.Value) : 0;
}
return result;
}
/// <summary>
/// Free-issue entitlement per scheme for unsaved invoice lines (editor preview). Lines carry IsFree / scheme id;
/// only the paid ones count toward the scheme rules.
/// </summary>
public async Task<Dictionary<Guid, int>> GetFreeEntitlementsAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines) =>
await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList());
/// <summary>
/// Keeps an invoice's free-issue lines within what the remaining paid lines still earn: when the paid quantity of the
/// qualifying item is lowered (or removed), the free quantity is reduced to match (a line that earns nothing is struck
/// out) and the PO's redemption is resized. Stages changes only. Returns true if any free line was changed.
/// </summary>
public async Task<bool> ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List<InvoiceItem> lines)
{
var freeLines = lines.Where(l => l.IsActive && l.IsFree && l.FK_SchemeId.HasValue).ToList();
if (freeLines.Count == 0) return false;
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
.ToList();
var entitled = await FreeEntitlementsAsync(freeLines.Select(l => l.FK_SchemeId!.Value), paid);
var redemptions = await repo.GetRedemptionsByShopPOAsync(shopPOId);
var changed = false;
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
{
var remaining = entitled[group.Key];
foreach (var line in group.Where(l => !l.IsRemoved).OrderBy(l => l.CreatedAt))
{
if (line.Quantity <= remaining) { remaining -= line.Quantity; continue; }
if (remaining == 0) line.IsRemoved = true;
else line.Quantity = remaining;
remaining = 0;
changed = true;
}
var redemption = redemptions.FirstOrDefault(r => r.FK_SchemeId == group.Key && r.FK_FreeItemId.HasValue);
if (redemption is not null)
redemption.FreeQty = group.Where(l => !l.IsRemoved).Sum(l => l.Quantity);
}
return changed;
}
/// <summary>
/// Discount the given schemes would give for these lines, without touching any redemption (editor preview).
/// </summary>
public async Task<decimal> CalculateDiscountForSchemesAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines)
{
var priced = lines.ToList();
decimal total = 0m;
foreach (var id in schemeIds.Distinct())
{
var scheme = await repo.GetByIdAsync(id);
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
if (eval is { Eligible: true, DiscountAmount: not null }) total += eval.DiscountAmount.Value;
}
return total;
}
/// <summary>
/// Applies the discount choice made on a Draft invoice (the percentage scheme the user accepted, or none) to the
/// PO's redemptions: newly chosen schemes are validated and added, unchosen ones removed. The amount itself is
/// then set by CalculateDiscountAsync when the totals are re-derived. Stages changes only. Null = leave as is.
/// </summary>
public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List<InvoiceItem> lines, List<Guid>? schemeIds)
{
if (schemeIds is null) return;
if (schemeIds.Distinct().Count() > 1)
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
var existing = (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue).ToList();
foreach (var r in existing.Where(r => !schemeIds.Contains(r.FK_SchemeId)))
repo.RemoveRedemption(r);
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
.ToList();
var added = schemeIds.Where(id => existing.All(r => r.FK_SchemeId != id)).ToList();
await ValidateDiscountSchemesAsync(storeId, DateTime.Now, paid, added);
foreach (var id in added)
await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = id, FK_ShopPOId = shopPOId, DiscountAmount = 0m });
}
/// <summary>Discounts already redeemed against a Shop PO, for display and for editing.</summary>
public async Task<List<ShopPODiscountResponse>> GetDiscountsForShopPOAsync(Guid shopPOId) =>
(await repo.GetRedemptionsByShopPOAsync(shopPOId))
.Where(r => r.DiscountAmount.HasValue)
.Select(r => new ShopPODiscountResponse(r.FK_SchemeId, r.Scheme?.Name ?? string.Empty, r.DiscountAmount!.Value))
.ToList();
/// <summary>
/// Brings the Shop PO's scheme redemptions in line with its saved lines: free-goods redemptions follow the
/// free lines (created, resized or removed), discount redemptions are recomputed or dropped if the order no
/// longer qualifies. Stages changes only — the caller owns the commit.
/// </summary>
public async Task SyncRedemptionsAsync(Guid shopPOId, List<ShopPOItem> lines, List<Guid>? discountSchemeIds = null)
{
var existing = await repo.GetRedemptionsByShopPOAsync(shopPOId);
var freeBySchemes = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue)
.GroupBy(l => l.FK_SchemeId!.Value).ToDictionary(g => g.Key, g => g.ToList());
foreach (var r in existing)
{
if (r.FK_FreeItemId.HasValue)
{
if (!freeBySchemes.ContainsKey(r.FK_SchemeId)) repo.RemoveRedemption(r);
}
else if (r.DiscountAmount.HasValue)
{
// A null list means "leave the accepted discounts alone" (just recompute them).
if (discountSchemeIds is not null && !discountSchemeIds.Contains(r.FK_SchemeId))
{
repo.RemoveRedemption(r);
continue;
}
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
if (eval is { Eligible: true, DiscountAmount: not null }) r.DiscountAmount = eval.DiscountAmount;
else repo.RemoveRedemption(r);
}
}
foreach (var schemeId in discountSchemeIds ?? [])
{
if (existing.Any(r => r.FK_SchemeId == schemeId && r.DiscountAmount.HasValue)) continue;
var scheme = await repo.GetByIdAsync(schemeId);
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
if (eval is { Eligible: true, DiscountAmount: not null })
await repo.AddRedemptionAsync(new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_ShopPOId = shopPOId,
DiscountAmount = eval.DiscountAmount
});
}
foreach (var (schemeId, group) in freeBySchemes)
{
var freeItem = await itemRepo.GetByIdAsync(group[0].FK_ItemId)
?? throw new InvalidOperationException("Free item not found.");
var qty = group.Sum(l => l.Quantity);
var redemption = existing.FirstOrDefault(r => r.FK_SchemeId == schemeId && r.FK_FreeItemId.HasValue);
if (redemption is null)
await repo.AddRedemptionAsync(new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_ShopPOId = shopPOId,
FK_FreeItemId = freeItem.Id,
FreeQty = qty,
FreeItemCostValue = freeItem.CostPrice ?? 0m
});
else
{
redemption.FK_FreeItemId = freeItem.Id;
redemption.FreeQty = qty;
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
}
}
}
/// <summary>
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
@@ -620,18 +299,14 @@ public class SchemeService(
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
var today = DateTime.Now;
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
return (false, "Scheme is not currently active (outside its date range).");
return (true, null);
}
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> allItems)
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
{
// Free-issue lines (added by a previous redemption) must not count toward a trigger/bundle
// quantity, otherwise the free units inflate the multiplier.
var items = allItems.Where(i => !i.IsFree && i.UnitPrice > 0).ToList();
switch (scheme.RuleType)
{
case SchemeRuleType.BuyXGetY:
@@ -708,9 +383,6 @@ 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)
@@ -739,10 +411,7 @@ public class SchemeService(
FK_ShopPOId = shopPOId,
FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m,
IsFree = true,
FK_SchemeId = scheme.Id,
SchemeName = scheme.Name
UnitPrice = 0m
});
redemption.FK_FreeItemId = freeItem.Id;
@@ -753,27 +422,7 @@ public class SchemeService(
await uow.BeginAsync(ct);
try
{
if (newItems.Count > 0)
{
await shopPORepo.AddItemsAsync(newItems);
// A still-draft invoice was copied from the PO before this line existed, so mirror it there.
if (shopPO.InvoiceId is Guid invoiceId &&
await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft })
{
await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem
{
FK_InvoiceId = invoiceId,
FK_ShopPOItemId = n.Id,
FK_ItemId = n.FK_ItemId,
Quantity = n.Quantity,
UnitPrice = 0m,
IsFree = true,
FK_SchemeId = n.FK_SchemeId,
SchemeName = n.SchemeName
}));
}
}
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct);
}
@@ -835,8 +484,6 @@ public class SchemeService(
if (freeItemId is null || freeQty is null or <= 0)
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
if (freeQty.Value != decimal.Truncate(freeQty.Value))
throw new InvalidOperationException("FreeQty must be a whole number (order lines hold whole units).");
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
throw new InvalidOperationException("Free item not found.");
}
@@ -30,16 +30,6 @@ 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)
{
+5 -11
View File
@@ -2,18 +2,13 @@
namespace Distribution.Features.ShopPO;
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive,
bool IsFree = false, Guid? SchemeId = null, string? SchemeName = null);
public record ShopPODiscountResponse(Guid SchemeId, string SchemeName, decimal Amount);
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
public record ShopPOResponse(
Guid Id,
@@ -39,5 +34,4 @@ public record ShopPOResponse(
DateTime? CancelledAt = null,
string? CancelReason = null,
string? CancelledByName = null,
string? CancelledByRole = null,
List<ShopPODiscountResponse>? Discounts = null);
string? CancelledByRole = null);
+29 -72
View File
@@ -1,7 +1,5 @@
using Distribution.Features.Invoice;
using Distribution.Features.Notifications;
using Distribution.Features.PriceList;
using Distribution.Features.Scheme;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -12,47 +10,16 @@ public class ShopPOService(
IShopPORepository repo,
IInvoiceRepository invoiceRepo,
PriceListService priceListService,
SchemeService schemeService,
INotificationService notifications,
IUnitOfWork uow)
{
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
{
if (line.IsFree)
return new ShopPOItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
UnitPrice = 0m,
IsFree = true,
FK_SchemeId = line.SchemeId
};
var unitPrice = line.UnitPrice
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
}
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
notifications.NotifyAsync(
type,
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
ct);
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
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,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
p.IsActive, p.CreatedAt,
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
@@ -60,8 +27,7 @@ public class ShopPOService(
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
p.CancelledBy, p.CancelledAt, p.CancelReason,
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
discounts ?? []);
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
{
@@ -86,14 +52,7 @@ public class ShopPOService(
var po = await repo.GetByIdAsync(id);
if (po is null) return null;
var users = await repo.GetUserInfoAsync(UserIds(po));
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
}
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
{
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
return items.Select(p => ToResponse(p, users)).ToList();
return ToResponse(po, users);
}
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
@@ -106,13 +65,19 @@ public class ShopPOService(
foreach (var line in request.Items)
{
var item = await BuildLineAsync(line, request.StoreId);
items.Add(item);
total += item.Quantity * item.UnitPrice;
}
var unitPrice = line.UnitPrice
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
items.Add(new ShopPOItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
var po = new Models.ShopPO
{
@@ -128,7 +93,6 @@ public class ShopPOService(
await repo.AddAsync(po);
foreach (var item in items) item.FK_ShopPOId = po.Id;
await repo.AddItemsAsync(items);
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -156,14 +120,20 @@ public class ShopPOService(
foreach (var line in request.Items)
{
var item = await BuildLineAsync(line, po.FK_StoreId);
item.FK_ShopPOId = id;
newItems.Add(item);
total += item.Quantity * item.UnitPrice;
}
var unitPrice = line.UnitPrice
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
newItems.Add(new ShopPOItem
{
FK_ShopPOId = id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
po.TotalAmount = total;
@@ -171,7 +141,6 @@ public class ShopPOService(
try
{
await repo.ReplaceItemsAsync(id, newItems);
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
repo.Update(po);
await uow.CommitAsync(ct);
}
@@ -199,8 +168,6 @@ 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);
}
@@ -226,25 +193,17 @@ public class ShopPOService(
CreatedBy = userId,
TotalAmount = po.TotalAmount
};
// Start the invoice at the discounted total so it matches what print/finalize will recompute.
invoice.DiscountAmount = Math.Min(await invoiceRepo.GetSchemeDiscountAsync(po.Id), po.TotalAmount);
invoice.TotalAmount = po.TotalAmount - invoice.DiscountAmount;
await uow.BeginAsync(ct);
try
{
await invoiceRepo.AddAsync(invoice);
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
await invoiceRepo.AddItemsAsync(lines);
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
po.InvoiceId = invoice.Id;
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!);
}
@@ -272,8 +231,6 @@ 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 -15
View File
@@ -1,4 +1,3 @@
using Distribution.Features.Notifications;
using Distribution.Features.Scheme;
using Distribution.Features.Stock;
using Distribution.Features.StoreCredit;
@@ -8,7 +7,7 @@ using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
@@ -149,7 +148,6 @@ 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
@@ -162,18 +160,6 @@ 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,5 +1,4 @@
using Distribution.Features.Grn;
using Distribution.Features.Notifications;
using Distribution.Features.Po;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -10,7 +9,6 @@ public class SupplierClaimService(
ISupplierClaimRepository repo,
IPoRepository poRepo,
IGrnRepository grnRepo,
INotificationService notifications,
IUnitOfWork uow)
{
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
@@ -151,19 +149,9 @@ 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)
@@ -184,8 +172,6 @@ public class SupplierClaimService(
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
return Map(claim);
}
}
@@ -1,6 +1,7 @@
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;
@@ -17,7 +18,8 @@ public class SupplierPaymentService(
IBankAccountRepository bankAccountRepo,
IChequeBookRepository chequeBookRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
ICurrentUserService currentUser,
GlPostingService gl)
{
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
@@ -161,14 +163,13 @@ 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,6 +98,12 @@ 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",
@@ -1,99 +0,0 @@
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");
}
}
}
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Distribution.Migrations
{
[DbContext(typeof(AppDBContext))]
[Migration("20261020075213_AddNotifications")]
partial class AddNotifications
[Migration("20261020082853_GlIntegration")]
partial class GlIntegration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -59,6 +59,9 @@ namespace Distribution.Migrations
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("GlAccountCode")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
@@ -628,6 +631,57 @@ 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")
@@ -1281,93 +1335,6 @@ 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")
@@ -1901,6 +1868,22 @@ 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
});
});
@@ -2618,7 +2601,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("e748cecd-7b03-41ba-96f4-e7e80d8b39f5"),
Id = new Guid("52dedbb8-6251-4131-82de-41c2064a663b"),
IsActive = true
},
new
@@ -2626,7 +2609,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("6ec338c9-dbe1-40b0-895c-228bcb439815"),
Id = new Guid("47450af3-cf95-4725-a208-eeca124022b7"),
IsActive = true
},
new
@@ -2634,7 +2617,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("eb390164-72ff-4b03-8d09-184f25fe2e0b"),
Id = new Guid("44deed45-fc6f-420c-a026-f4483efab782"),
IsActive = true
},
new
@@ -2642,7 +2625,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("e8b5c2b0-cdba-42f0-b1f0-32a892036c0a"),
Id = new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"),
IsActive = true
},
new
@@ -2650,7 +2633,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("48340fe7-3037-4ab1-8800-59763bce0855"),
Id = new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"),
IsActive = true
},
new
@@ -2658,7 +2641,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("d43d5a8b-4a0a-4a7a-9b34-7c0561ed9303"),
Id = new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"),
IsActive = true
},
new
@@ -2666,7 +2649,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("41c7bd2c-0253-4315-8594-811aec35f15c"),
Id = new Guid("16ad892c-b785-4872-a426-1015f32c90b9"),
IsActive = true
},
new
@@ -2674,7 +2657,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("87052d54-7319-473e-a46f-62d6477ef7d8"),
Id = new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"),
IsActive = true
},
new
@@ -2682,7 +2665,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("c0b0185e-3530-4cc8-9e6b-b51cd1187dda"),
Id = new Guid("498230b5-7d98-4826-afaf-944123658f69"),
IsActive = true
},
new
@@ -2690,7 +2673,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("6e649c16-703b-4420-ae23-8840d1dc247b"),
Id = new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"),
IsActive = true
},
new
@@ -2698,7 +2681,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("efe29c4c-353a-4b25-9279-6566c8ca1b3d"),
Id = new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"),
IsActive = true
},
new
@@ -2706,7 +2689,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("3aa9f6cd-0075-4ab2-88c4-c49e177d443a"),
Id = new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"),
IsActive = true
},
new
@@ -2714,7 +2697,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("258dfadf-292e-410c-a173-217983becc2b"),
Id = new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"),
IsActive = true
},
new
@@ -2722,7 +2705,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("c253907f-f84e-4ea5-af4a-0db56b89b82a"),
Id = new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"),
IsActive = true
},
new
@@ -2730,7 +2713,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("c47972c0-6a5f-47cf-ae30-cbf753a6fe94"),
Id = new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"),
IsActive = true
},
new
@@ -2738,7 +2721,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("ee4b11f3-78d4-4e6f-bb22-95f3f18be7e5"),
Id = new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"),
IsActive = true
},
new
@@ -2746,7 +2729,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("a0a39f4e-95c9-42bc-9803-a510fa4863d7"),
Id = new Guid("86229744-f68d-4e7e-9b07-2aad58584331"),
IsActive = true
},
new
@@ -2754,7 +2737,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("29e55c7f-92cb-47fd-b05c-ce038284ff39"),
Id = new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"),
IsActive = true
},
new
@@ -2762,7 +2745,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("7b675ce2-3949-4e1a-ad6e-e1a047d84ece"),
Id = new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"),
IsActive = true
},
new
@@ -2770,7 +2753,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("835c1000-3998-444c-b9bf-53b2ef810437"),
Id = new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"),
IsActive = true
},
new
@@ -2778,7 +2761,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("3c9d0a5d-059e-4e0b-b1a8-698ce2a076ae"),
Id = new Guid("48772797-f181-423d-bfb3-f6226a9abe11"),
IsActive = true
},
new
@@ -2786,7 +2769,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("f12fc85a-5a91-4e23-81de-0b497d4bc246"),
Id = new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"),
IsActive = true
},
new
@@ -2794,7 +2777,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("08005497-616c-4279-aa8a-162762d57a88"),
Id = new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"),
IsActive = true
},
new
@@ -2802,7 +2785,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("666a9e41-1c11-4af3-8f72-c80ba3734a48"),
Id = new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"),
IsActive = true
},
new
@@ -2810,7 +2793,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("87cd58d6-68f2-4de0-a0d8-5951a2281184"),
Id = new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"),
IsActive = true
},
new
@@ -2818,7 +2801,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("a7a0cb93-598b-4e21-9bab-ae5ba4da71d3"),
Id = new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"),
IsActive = true
},
new
@@ -2826,7 +2809,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("86bf0611-4798-4209-a5e4-a0064a5e95cd"),
Id = new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"),
IsActive = true
},
new
@@ -2834,7 +2817,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("bd386f32-0f7a-4954-9e85-15956b4fb718"),
Id = new Guid("8f63c426-38bb-456d-adff-75e477857607"),
IsActive = true
},
new
@@ -2842,7 +2825,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("9dd219a4-da77-49f5-8ea1-097dbba231ea"),
Id = new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"),
IsActive = true
},
new
@@ -2850,7 +2833,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("5caf3706-bde2-43bf-b9a8-d351500e6cba"),
Id = new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"),
IsActive = true
},
new
@@ -2858,7 +2841,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("63ba1d73-9819-4471-a89b-1eacade54d84"),
Id = new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"),
IsActive = true
},
new
@@ -2866,7 +2849,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("58398d5c-4687-4a7f-a205-d6a3ff365c6f"),
Id = new Guid("12e98296-456e-40a3-b11c-61d3393b335e"),
IsActive = true
},
new
@@ -2874,7 +2857,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("72761e2e-cd4f-44d1-b446-d5ab974374d4"),
Id = new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"),
IsActive = true
},
new
@@ -2882,7 +2865,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("66c1a66f-5d2d-4532-8f47-441a9a9df5c3"),
Id = new Guid("5e701ad5-f462-4642-86d1-303148323a32"),
IsActive = true
},
new
@@ -2890,7 +2873,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("6e37280f-b1cc-4aba-a8df-2f42ee1958a0"),
Id = new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"),
IsActive = true
},
new
@@ -2898,7 +2881,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("dd4149c0-8746-4ceb-92c2-27cec640fca2"),
Id = new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"),
IsActive = true
},
new
@@ -2906,7 +2889,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("e51495d1-bb65-4345-8156-73c791589d1f"),
Id = new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"),
IsActive = true
},
new
@@ -2914,7 +2897,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("ccf5af23-acd7-4bdb-a2ea-ec409a970add"),
Id = new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"),
IsActive = true
},
new
@@ -2922,7 +2905,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("11e001a5-7a47-4778-996b-0f2aa1e4ae73"),
Id = new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"),
IsActive = true
},
new
@@ -2930,7 +2913,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("6b1edf33-51c0-4cae-ba2f-9737e0c91e42"),
Id = new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"),
IsActive = true
},
new
@@ -2938,7 +2921,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("0a54915c-1660-4657-96fb-e55d01e8a31a"),
Id = new Guid("a2515783-f099-4332-a07c-be81f2e85160"),
IsActive = true
},
new
@@ -2946,7 +2929,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("646a6638-8520-4fd8-8608-aa7e29bb874d"),
Id = new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"),
IsActive = true
},
new
@@ -2954,7 +2937,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("8fe75d07-1c6c-4019-becc-d5e82f2cb777"),
Id = new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"),
IsActive = true
},
new
@@ -2962,7 +2945,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("05732dc6-a967-481b-b055-76ff68c5728e"),
Id = new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"),
IsActive = true
},
new
@@ -2970,7 +2953,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("864da17e-470f-41f5-a053-07f112c090ea"),
Id = new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"),
IsActive = true
},
new
@@ -2978,7 +2961,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("f8c31d84-f57f-4515-86f2-733442d9ea14"),
Id = new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"),
IsActive = true
},
new
@@ -2986,7 +2969,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("ba7e7d95-51f7-4ebb-b911-9bc18a104a39"),
Id = new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"),
IsActive = true
},
new
@@ -2994,7 +2977,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("b4cfa315-1b8a-4f61-af60-5389391f973c"),
Id = new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"),
IsActive = true
},
new
@@ -3002,7 +2985,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("a03e5eff-1925-4ddf-904b-32be0daed97b"),
Id = new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"),
IsActive = true
},
new
@@ -3010,7 +2993,23 @@ 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("be5ff487-33d9-4c63-81f4-fd7f6553c5bf"),
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"),
IsActive = true
});
});
@@ -3995,6 +3994,54 @@ 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")
@@ -5057,25 +5104,6 @@ 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")
@@ -5508,6 +5536,31 @@ 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")
@@ -5768,11 +5821,6 @@ 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,814 @@
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
@@ -1,81 +0,0 @@
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,157 +0,0 @@
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
@@ -1,46 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddInvoiceDiscount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "DiscountAmount",
table: "Invoices",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "Invoices",
keyColumn: "Id",
keyValue: new Guid("34500000-0000-0000-0000-000000000001"),
column: "DiscountAmount",
value: 0m);
migrationBuilder.UpdateData(
table: "Invoices",
keyColumn: "Id",
keyValue: new Guid("34500000-0000-0000-0000-000000000002"),
column: "DiscountAmount",
value: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DiscountAmount",
table: "Invoices");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,99 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddFreeIssueLines : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "FK_SchemeId",
table: "ShopPOItems",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<bool>(
name: "IsFree",
table: "ShopPOItems",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "SchemeName",
table: "ShopPOItems",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<Guid>(
name: "FK_SchemeId",
table: "InvoiceItems",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<bool>(
name: "IsFree",
table: "InvoiceItems",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "SchemeName",
table: "InvoiceItems",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
// Existing free-issue lines were plain zero-priced lines; flag them from their scheme redemption.
migrationBuilder.Sql(@"
UPDATE ShopPOItems i
JOIN SchemeRedemptions r ON r.FK_ShopPOId = i.FK_ShopPOId AND r.FK_FreeItemId = i.FK_ItemId
JOIN Schemes s ON s.Id = r.FK_SchemeId
SET i.IsFree = 1, i.FK_SchemeId = r.FK_SchemeId, i.SchemeName = s.Name
WHERE i.UnitPrice = 0 AND i.IsActive = 1;");
migrationBuilder.Sql(@"
UPDATE InvoiceItems ii
JOIN ShopPOItems pi ON ii.FK_ShopPOItemId = pi.Id
SET ii.IsFree = pi.IsFree, ii.FK_SchemeId = pi.FK_SchemeId, ii.SchemeName = pi.SchemeName
WHERE pi.IsFree = 1;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FK_SchemeId",
table: "ShopPOItems");
migrationBuilder.DropColumn(
name: "IsFree",
table: "ShopPOItems");
migrationBuilder.DropColumn(
name: "SchemeName",
table: "ShopPOItems");
migrationBuilder.DropColumn(
name: "FK_SchemeId",
table: "InvoiceItems");
migrationBuilder.DropColumn(
name: "IsFree",
table: "InvoiceItems");
migrationBuilder.DropColumn(
name: "SchemeName",
table: "InvoiceItems");
}
}
}
@@ -56,6 +56,9 @@ namespace Distribution.Migrations
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("GlAccountCode")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
@@ -625,6 +628,57 @@ 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")
@@ -848,9 +902,6 @@ 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)");
@@ -890,7 +941,6 @@ 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"),
@@ -906,7 +956,6 @@ 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,
@@ -917,132 +966,6 @@ namespace Distribution.Migrations
});
});
modelBuilder.Entity("Distribution.Models.InvoiceItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_InvoiceId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_SchemeId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ShopPOItemId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsFree")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsRemoved")
.HasColumnType("tinyint(1)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("SchemeName")
.HasColumnType("longtext");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_InvoiceId");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_ShopPOItemId");
b.ToTable("InvoiceItems");
});
modelBuilder.Entity("Distribution.Models.InvoiceVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedBy")
.HasColumnType("char(36)");
b.Property<Guid>("FK_InvoiceId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Stage")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.Property<int>("VersionNumber")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_InvoiceId", "VersionNumber")
.IsUnique();
b.ToTable("InvoiceVersions");
});
modelBuilder.Entity("Distribution.Models.InvoiceVersionItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_InvoiceVersionId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ShopPOItemId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsRemoved")
.HasColumnType("tinyint(1)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_InvoiceVersionId");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_ShopPOItemId");
b.ToTable("InvoiceVersionItems");
});
modelBuilder.Entity("Distribution.Models.Item", b =>
{
b.Property<Guid>("Id")
@@ -1409,93 +1332,6 @@ 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")
@@ -2029,6 +1865,22 @@ 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
});
});
@@ -2746,7 +2598,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("2a4abd94-1720-49a7-89f3-4e835781b40a"),
Id = new Guid("52dedbb8-6251-4131-82de-41c2064a663b"),
IsActive = true
},
new
@@ -2754,7 +2606,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("dea99413-dea7-4353-adb6-2684f628ed6d"),
Id = new Guid("47450af3-cf95-4725-a208-eeca124022b7"),
IsActive = true
},
new
@@ -2762,7 +2614,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("9ca45530-7f89-4aab-81f3-34b70ddf9bde"),
Id = new Guid("44deed45-fc6f-420c-a026-f4483efab782"),
IsActive = true
},
new
@@ -2770,7 +2622,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("12a89406-c43a-483f-9b78-97ca64aca95b"),
Id = new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"),
IsActive = true
},
new
@@ -2778,7 +2630,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("4859dde8-21b4-412b-9bfd-8adc5b68410a"),
Id = new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"),
IsActive = true
},
new
@@ -2786,7 +2638,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("74038a6f-5fd5-4bc5-a78a-a83602bc22db"),
Id = new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"),
IsActive = true
},
new
@@ -2794,7 +2646,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("ae20b416-a508-4ffc-92a1-e98aaeadb8b6"),
Id = new Guid("16ad892c-b785-4872-a426-1015f32c90b9"),
IsActive = true
},
new
@@ -2802,7 +2654,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("1fe41f0e-a01e-4481-b4f6-451de1e9481d"),
Id = new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"),
IsActive = true
},
new
@@ -2810,7 +2662,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("252ff69b-fe2b-48dc-97cc-29bc1b605c4d"),
Id = new Guid("498230b5-7d98-4826-afaf-944123658f69"),
IsActive = true
},
new
@@ -2818,7 +2670,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("78617da6-504a-4c8f-b4db-22679230b6de"),
Id = new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"),
IsActive = true
},
new
@@ -2826,7 +2678,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("731cc179-ec73-4b36-8345-3b7f961738cd"),
Id = new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"),
IsActive = true
},
new
@@ -2834,7 +2686,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("462c0177-9604-4dde-8946-be1f003c26d5"),
Id = new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"),
IsActive = true
},
new
@@ -2842,7 +2694,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("9401e464-23ea-4c0f-89b3-166a5a52c2b2"),
Id = new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"),
IsActive = true
},
new
@@ -2850,7 +2702,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("ad7d3861-64b7-482c-90d1-34a1c588a4bc"),
Id = new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"),
IsActive = true
},
new
@@ -2858,7 +2710,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("33bbfe87-566f-4f7f-a140-8557242b38bd"),
Id = new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"),
IsActive = true
},
new
@@ -2866,7 +2718,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("9e20caa7-c5fd-461b-8345-0688a436461f"),
Id = new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"),
IsActive = true
},
new
@@ -2874,7 +2726,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("75dfbd5d-43de-41d8-b8c3-1fc222d4ecec"),
Id = new Guid("86229744-f68d-4e7e-9b07-2aad58584331"),
IsActive = true
},
new
@@ -2882,7 +2734,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("52dcde7a-122e-4721-9c81-f1a413eff01e"),
Id = new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"),
IsActive = true
},
new
@@ -2890,7 +2742,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("1646eda4-31b4-47ba-b11b-77b5a1e389d4"),
Id = new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"),
IsActive = true
},
new
@@ -2898,7 +2750,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("cf3a7f0c-9877-494f-a07d-d693edec4cde"),
Id = new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"),
IsActive = true
},
new
@@ -2906,7 +2758,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("b36eefd7-54a5-465a-9b3b-43418b6c8f5b"),
Id = new Guid("48772797-f181-423d-bfb3-f6226a9abe11"),
IsActive = true
},
new
@@ -2914,7 +2766,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("615f12f1-07d3-463b-8259-f7e3926284b4"),
Id = new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"),
IsActive = true
},
new
@@ -2922,7 +2774,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("f3b1cf5c-ecb6-4b63-982c-feaa9afc73f5"),
Id = new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"),
IsActive = true
},
new
@@ -2930,7 +2782,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("b370f645-4b32-4275-8ae1-c92714d30731"),
Id = new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"),
IsActive = true
},
new
@@ -2938,7 +2790,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("1575ffb9-123f-4625-8c5f-d68d56a33bd3"),
Id = new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"),
IsActive = true
},
new
@@ -2946,7 +2798,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("8341015a-a078-4f4a-9d32-c8cc0c92654a"),
Id = new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"),
IsActive = true
},
new
@@ -2954,7 +2806,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("6078f921-206b-431c-956a-50ed8d99a004"),
Id = new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"),
IsActive = true
},
new
@@ -2962,7 +2814,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("67a3e0b2-26e7-4d57-8318-c17b6f4788f2"),
Id = new Guid("8f63c426-38bb-456d-adff-75e477857607"),
IsActive = true
},
new
@@ -2970,7 +2822,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("5949601b-3458-4fd3-adc7-3ca4ede91272"),
Id = new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"),
IsActive = true
},
new
@@ -2978,7 +2830,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("4c85ff7a-854b-4c07-8101-fa14a8699dd8"),
Id = new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"),
IsActive = true
},
new
@@ -2986,7 +2838,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("36837347-3d26-453b-a445-97331e82fa43"),
Id = new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"),
IsActive = true
},
new
@@ -2994,7 +2846,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("437e060a-ebf0-4cc0-bbb0-63ad7be9f177"),
Id = new Guid("12e98296-456e-40a3-b11c-61d3393b335e"),
IsActive = true
},
new
@@ -3002,7 +2854,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("7a589bc4-ce8c-437a-b91b-a4153f36c9fd"),
Id = new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"),
IsActive = true
},
new
@@ -3010,7 +2862,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("b13dffb9-2a6c-4d57-8194-d0fb93eedc0a"),
Id = new Guid("5e701ad5-f462-4642-86d1-303148323a32"),
IsActive = true
},
new
@@ -3018,7 +2870,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("fcbd982e-a963-4818-9361-d0af9b112e79"),
Id = new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"),
IsActive = true
},
new
@@ -3026,7 +2878,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("0e544a73-7236-41a7-acbb-3fe7b807383c"),
Id = new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"),
IsActive = true
},
new
@@ -3034,7 +2886,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("b85957c9-3f01-4043-8659-4cfee8b83c2c"),
Id = new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"),
IsActive = true
},
new
@@ -3042,7 +2894,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("42c8240d-dbfa-45b9-a87f-4130683722d1"),
Id = new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"),
IsActive = true
},
new
@@ -3050,7 +2902,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("b7693644-4a51-4fe9-93e3-e70b0093b772"),
Id = new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"),
IsActive = true
},
new
@@ -3058,7 +2910,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("34b1213b-bc79-4d79-b2e6-d607abdd9467"),
Id = new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"),
IsActive = true
},
new
@@ -3066,7 +2918,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("feb1c1cb-3b64-4ff7-8f67-ff1146ced327"),
Id = new Guid("a2515783-f099-4332-a07c-be81f2e85160"),
IsActive = true
},
new
@@ -3074,7 +2926,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("1e2ef870-17a5-494b-9d62-a2725fd420f4"),
Id = new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"),
IsActive = true
},
new
@@ -3082,7 +2934,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("efb242ec-dfee-4338-9b36-ecca208d4035"),
Id = new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"),
IsActive = true
},
new
@@ -3090,7 +2942,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("a9fb51c9-19b5-4297-b798-00ae0a6dced5"),
Id = new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"),
IsActive = true
},
new
@@ -3098,7 +2950,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("1ded2baa-f75d-4305-91e4-1778b0c43f95"),
Id = new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"),
IsActive = true
},
new
@@ -3106,7 +2958,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("ffe7a2b9-fe25-46a6-bd71-fa170cf39511"),
Id = new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"),
IsActive = true
},
new
@@ -3114,7 +2966,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("efca52dc-4ae3-4135-a293-77da540943d7"),
Id = new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"),
IsActive = true
},
new
@@ -3122,7 +2974,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("f1901597-9fb0-45a8-841f-eab0a7af9287"),
Id = new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"),
IsActive = true
},
new
@@ -3130,7 +2982,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("763e5759-b12c-434a-b117-ea09d773f4f4"),
Id = new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"),
IsActive = true
},
new
@@ -3138,7 +2990,23 @@ 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("ec9db3b2-59b0-48f3-a6e0-0c81d6fbc854"),
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"),
IsActive = true
});
});
@@ -3726,27 +3594,18 @@ namespace Distribution.Migrations
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_SchemeId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ShopPOId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsFree")
.HasColumnType("tinyint(1)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("QuantityFulfilled")
.HasColumnType("int");
b.Property<string>("SchemeName")
.HasColumnType("longtext");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
@@ -3766,7 +3625,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true,
IsFree = false,
Quantity = 30,
QuantityFulfilled = 30,
UnitPrice = 60m
@@ -3778,7 +3636,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true,
IsFree = false,
Quantity = 24,
QuantityFulfilled = 24,
UnitPrice = 50m
@@ -3790,7 +3647,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000002"),
IsActive = true,
IsFree = false,
Quantity = 10,
QuantityFulfilled = 0,
UnitPrice = 120m
@@ -3802,7 +3658,6 @@ namespace Distribution.Migrations
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
IsActive = true,
IsFree = false,
Quantity = 20,
QuantityFulfilled = 20,
UnitPrice = 120m
@@ -5129,69 +4984,6 @@ 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")
@@ -5309,25 +5101,6 @@ 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")
@@ -6030,16 +5803,9 @@ 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");
@@ -6052,11 +5818,6 @@ namespace Distribution.Migrations
b.Navigation("Orders");
});
modelBuilder.Entity("Distribution.Models.Notification", b =>
{
b.Navigation("Recipients");
});
modelBuilder.Entity("Distribution.Models.PriceList", b =>
{
b.Navigation("Items");
+3
View File
@@ -13,5 +13,8 @@ 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; }
}
}
-1
View File
@@ -18,7 +18,6 @@ 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 }
+24
View File
@@ -0,0 +1,24 @@
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; }
}
}
-5
View File
@@ -19,13 +19,8 @@ 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>();
}
}
-28
View File
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class InvoiceItem : Template
{
public Guid FK_InvoiceId { get; set; }
[ForeignKey("FK_InvoiceId")]
public Invoice Invoice { get; set; }
public Guid? FK_ShopPOItemId { get; set; }
[ForeignKey("FK_ShopPOItemId")]
public ShopPOItem? ShopPOItem { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public bool IsRemoved { get; set; }
/// <summary>Free issue copied from the Shop PO line (UnitPrice 0).</summary>
public bool IsFree { get; set; }
public Guid? FK_SchemeId { get; set; }
public string? SchemeName { get; set; }
}
}
-19
View File
@@ -1,19 +0,0 @@
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>();
}
}
-23
View File
@@ -1,23 +0,0 @@
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; }
}
}
-33
View File
@@ -1,33 +0,0 @@
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>();
}
}
@@ -1,19 +0,0 @@
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; }
}
}
-6
View File
@@ -15,11 +15,5 @@ namespace Distribution.Models
public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; }
/// <summary>Free issue granted by a scheme (UnitPrice 0); shown in the Free Issue column.</summary>
public bool IsFree { get; set; }
public Guid? FK_SchemeId { get; set; }
/// <summary>Scheme name when it was applied, so later scheme edits don't rewrite history.</summary>
public string? SchemeName { get; set; }
}
}
+10 -11
View File
@@ -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,15 +142,8 @@ builder.Services.AddScoped<ChequeBookService>();
builder.Services.AddScoped<ReceivedChequeService>();
builder.Services.AddScoped<SupplierPaymentService>();
builder.Services.AddScoped<SysConfigService>();
builder.Services.AddScoped<INotificationRepository, NotificationRepository>();
builder.Services.AddScoped<NotificationRecipientResolver>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<INotificationService>(sp => sp.GetRequiredService<NotificationService>());
builder.Services.AddSingleton<Microsoft.AspNetCore.SignalR.IUserIdProvider, NotificationUserIdProvider>();
builder.Services.AddHostedService<NotificationCleanupJob>();
builder.Services.AddSignalR()
.AddJsonProtocol(options => options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddScoped<GlSetupService>();
builder.Services.AddScoped<GlPostingService>();
builder.Services.AddMemoryCache();
builder.Services.AddHttpContextAccessor();
@@ -160,6 +153,13 @@ 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,6 +203,5 @@ app.UseMiddleware<AuthMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<NotificationHub>(NotificationHub.Path);
app.Run();
+6
View File
@@ -1,8 +1,14 @@
{
"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",
+9 -90
View File
@@ -76,7 +76,7 @@ order must contain every component in at least its listed `Qty` before the schem
SchemeRedemption
├── Id (Guid) — PK
├── FK_SchemeId (Guid) — required
├── FK_ShopPOId (Guid?) — nullable (see FieldInvoiceId below); the Shop PO the scheme was applied to
├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below)
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
│ entity exists yet, so this is an unconstrained Guid for now
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
@@ -87,8 +87,8 @@ SchemeRedemption
│ later changes
├── RedeemedAt (DateTime)
```
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`ShopPO` restrict-delete
(a scheme or Shop PO with redemptions can't be hard-deleted, and soft-delete leaves history intact).
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
### Item (extended)
```
@@ -124,10 +124,8 @@ columns/child table after the first migration had already been applied to the de
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
| — | `DELETE /api/schemes/{id}/bundle-components/{itemId}` | Removes one FreeBundle required-item row |
| — | `POST /api/schemes/suggest` | Body `{ storeId, items:[{itemId, quantity, unitPrice?}] }`; scheme suggestions for a Shop PO that is not saved yet |
| `evaluateSchemeEligibility(storeId, shopPOId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` (`orderId` is the Shop PO id) | Returns eligibility for every in-scope scheme, not just one |
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts |
| — | `POST /api/invoices/{id}/preview-totals` | Draft-editor preview of discount, total and free entitlement for unsaved lines |
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
@@ -218,18 +216,13 @@ Use this to add/edit one bundle component after creation.
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`.
(Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount
ceiling isn't implemented in code yet; this is recorded as the target behavior.)
2. `evaluateSchemeEligibility` matches a scheme to a store/Shop PO only if: the scheme's `RegionId` is
2. `evaluateSchemeEligibility` matches a scheme to a store/order only if: the scheme's `RegionId` is
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
Shop PO's date falls within `[DateRangeFrom, DateRangeTo]`. `DateRangeTo` is inclusive of the
whole calendar day (compared by date, so a scheme ending `2026-08-31` still applies on the 31st).
3. `BuyXGetY` eligibility: the Shop PO must contain at least `TriggerQty` of `TriggerItem`; the payout
order's date falls within `[DateRangeFrom, DateRangeTo]`.
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`).
Only **paid** lines (`UnitPrice > 0`) count toward `boughtQty` — free lines added by an earlier
redemption never count toward a trigger, so "buy 10 get 1 of the same item" can't self-inflate.
4. `FreeBundle` eligibility: the Shop PO must contain every `SchemeBundleComponent` in at least its
listed `Qty` (paid lines only, same as rule 3); payout is the flat `FreeQty` of `FreeItem` (no multiplier).
`FreeQty` must be a whole number (validated on create / `PUT .../free-goods-rule`), because
Shop PO lines hold whole units.
4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`,
`Quantity = FreeQty`), rather than only recording a discount amount — the free line still needs to
be picked, delivered, and stock-tracked like any other sold line.
@@ -247,80 +240,6 @@ Use this to add/edit one bundle component after creation.
---
## Offers on the PO and Invoice (implemented)
Nothing is added silently: the system **suggests**, the user **accepts**, and what was accepted stays **editable**.
### 1. On the Shop PO (new and edit screens)
1. While paid lines are entered, the screen calls `POST /api/schemes/suggest` (`{ storeId, items:[{itemId, quantity, unitPrice?}] }`).
The server returns the in-scope schemes (region, segment, date) the lines qualify for, plus Buy & get free /
Combo gift schemes the cart is part-way to (`reason` doubles as a nudge, e.g. "Requires 10 of the trigger item;
ShopPO has 6").
2. The **Offers & Free Issues** panel shows:
- **Free goods** (BuyXGetY / FreeBundle): "Qualifies for N free X" with **Add free**. The accepted free line is
editable (qty, remove) and is saved as a line with `isFree = true` and the `schemeId`.
- **Percentage discount** (FlatPercent / SlabDiscount): "Saves Rs. X" with **Apply discount**. Only one discount
per order. The accepted scheme ids are sent as `discountSchemeIds` on create / update-items.
3. If a later paid-qty change shrinks what the order earns, the screen trims the free qty / drops the discount and
says so. The server enforces the same rules on save: free qty within the entitlement, free item in stock, scheme
active and in scope, at most one discount, order still qualifies.
4. Saving keeps `SchemeRedemption` in step with the lines: created or resized per free line, removed with its line,
and discount redemptions created, recomputed, or dropped if the order stops qualifying.
5. `GET /api/shop-pos/{id}` returns `discounts[]` (scheme, amount) so the edit screen starts with the right state and
the PO view shows a "Scheme discount" row above the total. `PO.TotalAmount` stays the full lines total.
### 2. On the invoice
- The invoice copies the PO lines, including free lines (`IsFree`, `FK_SchemeId`, `SchemeName`). Free lines show in a
**Free Issue** column (a "-" in the cell that does not apply), are listed **after all paid lines**, are zero-priced
(price cannot be edited) and can be lowered like any line while the invoice is Draft.
- `Invoice.TotalAmount = billable lines - DiscountAmount`. When the invoice is created from a PO it starts at the
discounted total.
- **Draft invoice editor** (`/sales/invoices/{id}/edit`):
- A **Scheme discount** box lists the flat / slab discounts the current lines qualify for with **Apply discount** /
**Remove**. This also covers a PO that was confirmed before a discount was applied. `PUT /api/invoices/{id}/items`
accepts `discountSchemeIds` (null = leave as is).
- `POST /api/invoices/{id}/preview-totals` (`{ items:[{itemId, quantity, unitPrice, isFree, isRemoved, schemeId}], discountSchemeIds? }`)
returns the discount, total and per-scheme free entitlement for the unsaved lines; the editor calls it (debounced)
so the Scheme discount row and Total update while typing. It changes nothing.
- **Recalculation.** On every line change (add, remove, restore, re-price, quantity) and again at print and finalize,
`InvoiceService.ApplyTotalsAsync` first reconciles free lines, then re-evaluates the discount against the current lines:
- FlatPercent stays the set % of the current lines total; SlabDiscount can move up or down a level, or drop to 0 and
return if the lines requalify. Free lines and struck-out lines are ignored. The redemption's `DiscountAmount` is
updated with it, so scheme spend / ROI stay correct.
- **Free lines only shrink automatically.** If the paid quantity that earned a free issue is lowered or removed, the free
quantity is reduced to what is still earned (a line that earns nothing is struck out, keeping its quantity so it can be
restored) and the PO's free-goods redemption is resized. Free quantity is never raised automatically; a person raises it,
up to what the scheme earns.
- The invoice page's Items table ends with **Subtotal / Scheme discount / Total**, and the printed A4 and receipt copies show
Subtotal, Scheme discount, Total (Paid / Balance Due use the discounted total).
- The older `POST /api/orders/{id}/apply-scheme` endpoint still works for an already-saved PO and flags the line the same way
(and mirrors a free line onto a still-Draft invoice).
### 3. Scheme setup screens
`/pricing/schemes/new`, `/pricing/schemes/{id}` (read-only) and `/pricing/schemes/{id}/edit` are full pages (no modal) built on one
form. Rule types are shown in plain words: **Buy & get free** (BuyXGetY), **Combo gift** (FreeBundle), **Flat discount**
(FlatPercent), **Spend more, save more** (SlabDiscount); the fields adapt to the type (product dropdowns, quantities, % and
levels) with a live summary sentence. Required fields come first; Active, then the optional Region / Store segment last. The scheme
name is optional (generated from the offer when blank). The rule type cannot change after creation.
### Data
`ShopPOItem` and `InvoiceItem` gained `IsFree`, `FK_SchemeId` (plain Guid, no FK constraint) and a `SchemeName` snapshot. Migration
`AddFreeIssueLines` also back-fills existing zero-priced redemption lines.
### Not done yet
`SchemeRedemption.FK_InvoiceId` (`FieldInvoiceId` is still an unused placeholder); invoice-version snapshots do not record `IsFree`;
no hint or one-click "add more free units" when the paid quantity rises; scope (date / region) is checked when a discount is first
applied, not re-checked on later invoice edits; Slab "close to the next level" hints are not shown.
## Known Gaps vs. Enterprise Practice
Scheme code, lifecycle (Draft/Approved/Active/Expired), priority and stackable/exclusive flags;
budget and per-store caps; triggers on product groups/brands/categories or order value; tiered
free-goods slabs; funding source (company vs supplier — ties into SupplierClaims and GRN
`FreeQuantity`); stock reservation for the free item (the current check only sums balances).
---
## Open Questions
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
+34 -297
View File
@@ -1,330 +1,67 @@
# 21 — Notifications & Alerts
**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
**Status**: Not Started
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
---
## Goal
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.
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.
---
## Implemented now — who gets which notification
## Background / Context
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.
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)).
---
## Flow
## Data Model Changes
```
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
```
### Notification
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
`ReadAt` (nullable).
---
## Decisions
## API Endpoints
| Question | Decision |
| Function reference | Endpoint intent |
|---|---|
| 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) |
| `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` |
---
## Data Model
## Business Rules
**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.
- 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.
---
## Open Questions
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.
- 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)).
---
## Build order
## Implementation Notes
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.
> Fill this section in during planning discussion before writing any code.
@@ -82,91 +82,6 @@ Invoice
├── IsActive, CreatedAt — from Template
```
### Invoice Line Items & Version History
> **Implemented.** Class/column names follow the code: the PO is `ShopPO`/`ShopPOItem`, and the
> invoice FK is `FK_ShopPOId` (not `FK_OrderId` as sketched above).
The invoice no longer bills straight from the PO. It owns its own lines, and every change to them is
snapshotted, so the original, printed and finalized quantities can all be compared.
```
InvoiceItem — the live billing lines
├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
├── UnitPrice (decimal)
├── IsFree, FK_SchemeId, SchemeName — free issue copied from the PO line (UnitPrice 0); see 17_promotions_schemes.md
└── IsActive, CreatedAt — from Template
InvoiceVersion — immutable snapshot of the invoice
├── FK_InvoiceId, VersionNumber — unique per invoice, starts at 1
├── Stage (InvoiceVersionStage) — Created, DraftEdited, Printed, Finalized
├── TotalAmount, CreatedBy
└── Items → InvoiceVersionItem — FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice
```
- **Add / remove lines (Draft):** `PUT /api/invoices/{id}/items` takes edits keyed by `invoiceItemId`
(change quantity/price, or `isRemoved: true` to strike the line — it is kept, excluded from the
total, shown struck through on the invoice view and print copy in every status, and can be
restored while Draft) or by `itemId` (add a catalog item
that was not on the PO; `unitPrice` required, quantity > 0, no duplicate of a live line). Added
lines have no `ShopPOItem` (`FK_ShopPOItemId` is nullable → no `orderedQuantity`, no max). Versions
store `IsRemoved` per line. At `Finalize`, adjustments are keyed by `invoiceItemId` and can also
set `isRemoved`. When the loading sheet is linked to invoices, it must count added lines.
- **Original PO quantity** stays on `ShopPOItem.Quantity` (locked once the PO is confirmed) and is
returned as `orderedQuantity` on each invoice line.
- `InvoiceItem` rows are copied from the PO's active lines at invoice creation (Mode A and Mode B).
- A version is written, in the same transaction as the change, when the invoice is:
| Stage | Written when |
|---|---|
| `Created` (v1) | Invoice created |
| `DraftEdited` | `PUT /api/invoices/{id}/items` while `Draft` |
| `Printed` | `POST /api/invoices/{id}/print` — the quantities on the paper copy |
| `Finalized` | `POST /api/invoices/{id}/finalize` — after the agent's edits are applied |
- Comparing `Printed` vs `Finalized` shows what the agent changed on paper.
- Migrations `AddInvoiceItems` and `AddInvoiceVersions` backfill lines and one baseline version for
invoices that already existed (stage mapped from the invoice's status at migration time, so they
have no earlier history).
### Invoice Screens & Printing (frontend)
> **Implemented** in `distribution_fe`.
**Invoice document** — one shared component (`components/invoice/invoice-template.tsx`) renders the
invoice for the preview, the draft editor and the print tab:
- **A4**: `INVOICE` header with Invoice No. (`INV-xxxxxxxx`), date and PO ref; a Status row shown
until the invoice is Finalized; *Bill To* (store name, plus address/phone from the PO); item table
(Item · Qty · Unit Price · Amount); totals (Subtotal, Total, and Paid / Balance Due when payments
exist); payments list; notes lines for the agent's edits on non-finalized copies; signature lines.
Soft slate rules, no heavy colour.
- **80mm / 58mm receipts**: single black-and-white column with dashed rules (`*** DRAFT ***` /
`*** PRINTED ***`, `qty x price` lines, heavy-ruled TOTAL, Paid/Balance, payments, "Thank you!").
- Money `Rs. 12,450.00`, quantities up to 3 decimals, dates `dd/MM/yyyy`.
- Deleted lines are shown **struck through in every status** and excluded from all totals.
- Scheme discount: shown as Subtotal / Scheme discount / Total (`Invoice.DiscountAmount`), re-derived from the current lines on every edit, print and finalize (see 17_promotions_schemes.md). Free-issue lines get a **Free Issue** column and come after the paid lines.
- Not included (no data in the app): company/"From" block, logo, bank details, tax, due date.
**Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a
paper-size selector (A4 / 80mm / 58mm), sets `@page` with `margin: 0` (so the browser's own
URL/date header and footer are not printed; the A4 sheet pads itself with 14mm), and opens the
print dialog automatically. *Print* on a Draft asks who printed it, calls `POST /print`, then opens
this tab (the tab is opened inside the click so popup blockers allow it). Printed/Finalized
invoices have *Reprint*, which opens the tab without changing status.
**Draft editor** — pencil icon (Draft only, also *Edit Items* on the invoice page) opens a full page, `/sales/invoices/{id}/edit` (not a modal), showing the invoice template in edit mode: quantity/price
inputs, a trash icon per line (undo icon restores a deleted line), and an add-item row aligned to
the table columns (item dropdown excluding items already on the invoice, quantity, price
auto-filled from the store's price list, Enter or **+** to add). Save is disabled until something
changes, a summary shows what is pending (the browser warns before leaving with unsaved edits), and an item picked but not yet added is included on save. Non-Draft invoices redirect back to the invoice page.
Each save writes a `DraftEdited` version.
**Configuration** — *Invoice Creation* card: two toggles, **Auto** (Mode A) and **Manual** (Mode B,
default), mutually exclusive (turning one on turns the other off; the active one is locked on), each
with a one-line explanation and a process line (`Confirm PO → … → Edit draft → Print → Finalize`).
### New Enum — `InvoiceStatus`
```csharp
@@ -225,7 +140,6 @@ 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. |
@@ -248,10 +162,7 @@ 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 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).
reflects the current line items.
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.
@@ -290,11 +201,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`** — *partly resolved:* quantity adjustments
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
Returns and damage are still open, as is the payload shape for them. Scheme discounts and free issues are now handled
by the invoice itself (re-evaluated on every edit, print and finalize; free lines only ever shrink automatically) — see
[17_promotions_schemes.md](../17_promotions_schemes.md).
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
---
@@ -35,28 +35,10 @@ 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`.
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**.
(No frontend UI currently calls this endpoint — only the plain `Confirm` action is wired up.)
- Lifecycle: `Draft → Printed → Finalized`.
- 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).
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
quantity) and recomputes `TotalAmount` from the adjusted lines.
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
`Pending → PartiallyPaid → Paid`.
@@ -7,8 +7,6 @@ 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
@@ -62,8 +60,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-4 xl:grid-cols-7">
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
<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>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
@@ -177,7 +175,6 @@ 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}
@@ -187,11 +184,12 @@ 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-4 xl:grid-cols-7">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{kpi('Net Sales', 'netSales')}
{kpi('Gross Sales', 'grossSales')}
{kpi('Total Orders', 'orders', false)}
@@ -206,40 +204,16 @@ 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 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.
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.
</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<SalesTrendInteractive className="md:col-span-2" />
<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>
<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>
@@ -258,7 +232,9 @@ export function SalesSection({ range }: { range: DateRange }) {
])}
/>
</ChartCard>
<RegionSalesPie rows={sales.byRegion} />
<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>
<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 { StockMovementResponse } from '@/types/api'
import type { LowStockItemResponse, 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,6 +54,7 @@ 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' },
@@ -62,7 +63,7 @@ const WIDGET_META: WidgetMeta[] = [
const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
]
@@ -152,6 +153,7 @@ interface DashboardData {
confirmedSheetsCount: number
pendingTasksCount: number
partialTasksCount: number
lowStock: LowStockItemResponse[]
movements: StockMovementResponse[]
warehouseBalances: WarehouseBalanceRow[]
outstandingAmount: number
@@ -171,7 +173,7 @@ function emptyDashboardData(): DashboardData {
openRides: [],
plannedSheetsCount: 0, confirmedSheetsCount: 0,
pendingTasksCount: 0, partialTasksCount: 0,
movements: [], warehouseBalances: [],
lowStock: [], movements: [], warehouseBalances: [],
outstandingAmount: 0, outstandingInvoiceCount: 0,
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
@@ -234,6 +236,14 @@ 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 })
@@ -390,6 +400,24 @@ 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>
@@ -1,123 +0,0 @@
'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>
)
}
@@ -1,133 +0,0 @@
'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>
)
}
@@ -1,95 +0,0 @@
'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>
)
}
@@ -0,0 +1,350 @@
'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&apos;s sales and expenses into Retained Earnings with one closing entry and locks the year&apos;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>
)}
</>
)
}
@@ -0,0 +1,5 @@
import { GeneralLedgerClient } from './_components/general-ledger-client'
export default function GeneralLedgerPage() {
return <GeneralLedgerClient />
}
@@ -2,7 +2,6 @@
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 (
@@ -12,6 +11,7 @@ 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,9 +19,6 @@ 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>
)
+16 -21
View File
@@ -2,8 +2,6 @@ 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({
@@ -12,24 +10,21 @@ export default function DashboardLayout({
children: React.ReactNode
}) {
return (
<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>
<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>
)
}
@@ -1,133 +0,0 @@
'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>
)
}
@@ -1,5 +0,0 @@
import { NotificationsClient } from './_components/notifications-client'
export default function NotificationsPage() {
return <NotificationsClient />
}
@@ -1,30 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
import type { SchemeResponse } from '@/types/api'
import { SchemeForm } from '../../_components/scheme-form'
export default function EditSchemePage() {
const router = useRouter()
const { id } = useParams<{ id: string }>()
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
schemesApi
.get(id)
.then(setScheme)
.catch((err) => {
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
router.push('/pricing/schemes')
})
.finally(() => setLoading(false))
}, [id, router])
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
return <SchemeForm scheme={scheme} />
}
@@ -1,30 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
import type { SchemeResponse } from '@/types/api'
import { SchemeForm } from '../_components/scheme-form'
export default function SchemeDetailsPage() {
const router = useRouter()
const { id } = useParams<{ id: string }>()
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
schemesApi
.get(id)
.then(setScheme)
.catch((err) => {
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
router.push('/pricing/schemes')
})
.finally(() => setLoading(false))
}, [id, router])
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
return <SchemeForm scheme={scheme} readOnly />
}
@@ -1,602 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useFieldArray, useWatch } from 'react-hook-form'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, ArrowLeft01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import {
schemes as schemesApi,
regions as regionsApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { SchemeResponse, RegionResponse, ItemSummaryResponse, SchemeRuleType } from '@/types/api'
import { RULE_META, parseRuleConfig } from './scheme-labels'
const RULE_ORDER: SchemeRuleType[] = ['BuyXGetY', 'FreeBundle', 'FlatPercent', 'SlabDiscount']
const schema = z
.object({
name: z.string(),
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
regionId: z.string().optional(),
eligibleSegment: z.string().optional(),
dateRangeFrom: z.string().min(1, 'Required'),
dateRangeTo: z.string().min(1, 'Required'),
isActive: z.boolean(),
// Buy & get free
triggerItemId: z.string(),
triggerQty: z.number(),
// Buy & get free + combo gift: what the store receives
freeItemId: z.string(),
freeQty: z.number(),
// Combo gift: the products that must be bought together
bundle: z.array(z.object({ itemId: z.string(), qty: z.number() })),
// Flat discount
discountPercent: z.number(),
// Spend more, save more
tiers: z.array(z.object({ minOrderAmount: z.number(), discountPercent: z.number() })),
})
.superRefine((v, ctx) => {
const fail = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path, message })
const wholeQty = (n: number) => Number.isInteger(n) && n >= 1
const pct = (n: number) => n > 0 && n <= 100
if (v.dateRangeFrom && v.dateRangeTo && v.dateRangeFrom > v.dateRangeTo)
fail(['dateRangeTo'], 'End date must not be before the start date')
if (v.ruleType === 'BuyXGetY') {
if (!v.triggerItemId) fail(['triggerItemId'], 'Choose the product the store must buy')
if (!wholeQty(v.triggerQty)) fail(['triggerQty'], 'Enter a whole number, 1 or more')
}
if (v.ruleType === 'BuyXGetY' || v.ruleType === 'FreeBundle') {
if (!v.freeItemId) fail(['freeItemId'], 'Choose the free product')
if (!wholeQty(v.freeQty)) fail(['freeQty'], 'Enter a whole number, 1 or more')
}
if (v.ruleType === 'FreeBundle') {
if (v.bundle.length === 0) fail(['bundle'], 'Add at least one product')
v.bundle.forEach((b, i) => {
if (!b.itemId) fail(['bundle', i, 'itemId'], 'Choose a product')
if (!wholeQty(b.qty)) fail(['bundle', i, 'qty'], 'Whole number, 1 or more')
})
const ids = v.bundle.map((b) => b.itemId).filter(Boolean)
if (new Set(ids).size !== ids.length) fail(['bundle'], 'Each product can only be listed once')
}
if (v.ruleType === 'FlatPercent' && !pct(v.discountPercent))
fail(['discountPercent'], 'Enter a percentage between 1 and 100')
if (v.ruleType === 'SlabDiscount') {
if (v.tiers.length === 0) fail(['tiers'], 'Add at least one level')
v.tiers.forEach((t, i) => {
if (!(t.minOrderAmount > 0)) fail(['tiers', i, 'minOrderAmount'], 'Enter an amount above 0')
if (!pct(t.discountPercent)) fail(['tiers', i, 'discountPercent'], '1 to 100')
})
}
})
type FormValues = z.infer<typeof schema>
const EMPTY: FormValues = {
name: '',
ruleType: 'BuyXGetY',
regionId: '',
eligibleSegment: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
triggerItemId: '',
triggerQty: 10,
freeItemId: '',
freeQty: 1,
bundle: [{ itemId: '', qty: 1 }],
discountPercent: 10,
tiers: [{ minOrderAmount: 5000, discountPercent: 5 }],
}
function toFormValues(scheme: SchemeResponse): FormValues {
const cfg = parseRuleConfig(scheme)
return {
...EMPTY,
name: scheme.name,
ruleType: scheme.ruleType,
regionId: scheme.regionId ?? '',
eligibleSegment: scheme.eligibleSegment ?? '',
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
dateRangeTo: scheme.dateRangeTo.split('T')[0],
isActive: scheme.isActive,
triggerItemId: scheme.triggerItemId ?? '',
triggerQty: scheme.triggerQty ?? EMPTY.triggerQty,
freeItemId: scheme.freeItemId ?? '',
freeQty: scheme.freeQty ?? EMPTY.freeQty,
bundle: scheme.bundleComponents.length
? scheme.bundleComponents.map((c) => ({ itemId: c.itemId, qty: c.qty }))
: EMPTY.bundle,
discountPercent: cfg.discountPercent || EMPTY.discountPercent,
tiers: cfg.tiers.length ? cfg.tiers : EMPTY.tiers,
}
}
interface SchemeFormProps {
/** null = create a new scheme, otherwise edit this one. */
scheme: SchemeResponse | null
/** Show the scheme exactly as the edit page does, but locked, with an Edit button. */
readOnly?: boolean
}
export function SchemeForm({ scheme, readOnly = false }: SchemeFormProps) {
const router = useRouter()
const goBack = () => router.push('/pricing/schemes')
const isEdit = !!scheme
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: EMPTY })
const bundle = useFieldArray({ control: form.control, name: 'bundle' })
const tiers = useFieldArray({ control: form.control, name: 'tiers' })
const v = useWatch({ control: form.control }) as FormValues
const ruleType = v.ruleType ?? 'BuyXGetY'
useEffect(() => {
form.reset(scheme ? toFormValues(scheme) : EMPTY)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheme])
useEffect(() => {
Promise.all([regionsApi.list({ pageSize: 100 }), itemsApi.list({ pageSize: 200 })])
.then(([regions, items]) => {
setRegionsList(getPaginatedItems(regions))
setItemsList(getPaginatedItems(items))
})
.catch(() => toast.add({ title: 'Failed to load regions or products', type: 'error' }))
}, [])
const itemName = (id?: string) => itemsList.find((i) => i.id === id)?.name
// Live sentence so the user can check the offer reads the way they intended.
const preview = useMemo(() => {
const n = (x?: number) => (x && x > 0 ? x : '?')
if (ruleType === 'BuyXGetY') {
const buy = itemName(v.triggerItemId) ?? 'a product'
const free = itemName(v.freeItemId) ?? 'a product'
return `Buy ${n(v.triggerQty)} × ${buy}, get ${n(v.freeQty)} × ${free} free. Repeats: buying ${n(v.triggerQty && v.triggerQty * 2)} earns ${n(v.freeQty && v.freeQty * 2)} free.`
}
if (ruleType === 'FreeBundle') {
const parts = (v.bundle ?? []).map((b) => `${n(b.qty)} × ${itemName(b.itemId) ?? 'a product'}`).join(' + ')
return `Buy ${parts || 'products'} together, get ${n(v.freeQty)} × ${itemName(v.freeItemId) ?? 'a product'} free (once per order).`
}
if (ruleType === 'FlatPercent') return `${n(v.discountPercent)}% off the whole order.`
const levels = [...(v.tiers ?? [])]
.sort((a, b) => a.minOrderAmount - b.minOrderAmount)
.map((t) => `${n(t.discountPercent)}% off from Rs. ${(t.minOrderAmount || 0).toLocaleString()}`)
return levels.length ? `${levels.join('; ')}. The highest level the order reaches applies.` : 'Add at least one level.'
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [v, ruleType, itemsList])
async function onSubmit(values: FormValues) {
const isGoods = values.ruleType === 'BuyXGetY' || values.ruleType === 'FreeBundle'
const ruleConfig =
values.ruleType === 'FlatPercent'
? JSON.stringify({ discountPercent: values.discountPercent })
: values.ruleType === 'SlabDiscount'
? JSON.stringify({ tiers: values.tiers })
: null
// The name is optional: when left blank, describe the offer itself (e.g. "Buy 10 Soap, get 1 Soap free").
const n = (id: string) => itemName(id) ?? 'item'
const autoName =
values.ruleType === 'BuyXGetY'
? `Buy ${values.triggerQty} ${n(values.triggerItemId)}, get ${values.freeQty} ${n(values.freeItemId)} free`
: values.ruleType === 'FreeBundle'
? `Buy ${values.bundle.map((b) => `${b.qty} ${n(b.itemId)}`).join(' + ')}, get ${values.freeQty} ${n(values.freeItemId)} free`
: values.ruleType === 'FlatPercent'
? `${values.discountPercent}% off the order`
: `Up to ${Math.max(...values.tiers.map((t) => t.discountPercent))}% off by order value`
const common = {
name: values.name.trim() || autoName,
ruleConfig,
regionId: values.regionId || null,
eligibleSegment: values.eligibleSegment?.trim() || null,
dateRangeFrom: values.dateRangeFrom,
dateRangeTo: values.dateRangeTo,
isActive: values.isActive,
}
const goods = isGoods
? {
triggerItemId: values.ruleType === 'BuyXGetY' ? values.triggerItemId : null,
triggerQty: values.ruleType === 'BuyXGetY' ? values.triggerQty : undefined,
freeItemId: values.freeItemId,
freeQty: values.freeQty,
}
: {}
try {
if (!scheme) {
await schemesApi.create({
...common,
ruleType: values.ruleType,
...goods,
bundleComponents:
values.ruleType === 'FreeBundle' ? values.bundle.map((b) => ({ itemId: b.itemId, qty: b.qty })) : undefined,
})
toast.add({ title: 'Scheme created', type: 'success' })
} else {
// The rule type is fixed once created; update the basics, then the free-goods rule and bundle rows.
await schemesApi.update(scheme.id, { ...common, agentLimitExempt: scheme.agentLimitExempt })
if (isGoods) {
await schemesApi.setFreeGoodsRule(scheme.id, {
triggerItemId: goods.triggerItemId,
triggerQty: goods.triggerQty,
freeItemId: values.freeItemId,
freeQty: values.freeQty,
})
}
if (values.ruleType === 'FreeBundle') {
const keep = new Set(values.bundle.map((b) => b.itemId))
for (const old of scheme.bundleComponents) {
if (!keep.has(old.itemId)) await schemesApi.removeBundleComponent(scheme.id, old.itemId)
}
for (const b of values.bundle) await schemesApi.upsertBundleComponent(scheme.id, b.itemId, b.qty)
}
toast.add({ title: 'Scheme updated', type: 'success' })
}
goBack()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
const itemSelect = (value: string, onChange: (v: string) => void, placeholder: string, invalid?: boolean) => (
<Select value={value} onValueChange={(x) => onChange(x ?? '')}>
<SelectTrigger aria-invalid={invalid}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{itemsList.map((i) => (
<SelectItem key={i.id} value={i.id}>
{i.name} — {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
)
const numberInput = (value: number, onChange: (n: number) => void, invalid?: boolean, props?: { min?: number; max?: number; step?: string }) => (
<Input
type="number"
value={Number.isFinite(value) ? value : ''}
onChange={(e) => onChange(Number(e.target.value))}
aria-invalid={invalid}
{...props}
/>
)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{readOnly ? 'Scheme Details' : isEdit ? 'Edit Scheme' : 'Add Scheme'}
</h1>
<p className="text-sm text-muted-foreground">
{readOnly
? 'How this offer is set up.'
: isEdit ? 'Update the offer below.' : 'Pick the kind of offer, then fill in the details.'}
</p>
</div>
{readOnly && scheme && (
<Button type="button" className="ml-auto" onClick={() => router.push(`/pricing/schemes/${scheme.id}/edit`)}>
Edit Scheme
</Button>
)}
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<fieldset disabled={readOnly} className="m-0 min-w-0 rounded-2xl border p-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Scheme name"
description="Optional. If left blank, the name is created from the offer."
render={({ field, invalid }) => (
<Input
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Soap — buy 10 get 1 free (optional)"
aria-invalid={invalid}
/>
)}
/>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-foreground">What kind of offer is it?</p>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
{RULE_ORDER.map((type) => {
const selected = ruleType === type
return (
<button
key={type}
type="button"
disabled={isEdit && !selected}
onClick={() => form.setValue('ruleType', type, { shouldValidate: false })}
className={`rounded-xl border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
}`}
>
<p className="text-sm font-medium text-foreground">{RULE_META[type].label}</p>
<p className="text-xs text-muted-foreground">{RULE_META[type].description}</p>
<p className="mt-1 text-xs italic text-muted-foreground">e.g. {RULE_META[type].example}</p>
</button>
)
})}
</div>
{isEdit && <p className="text-xs text-muted-foreground">The kind of offer can&apos;t be changed after it is created.</p>}
</div>
{/* ── Buy & get free ─────────────────────────────────────────── */}
{ruleType === 'BuyXGetY' && (
<div className="flex flex-col gap-4 rounded-xl border p-4">
<p className="text-sm font-medium text-foreground">The store buys</p>
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<FormField
control={form.control}
name="triggerItemId"
label="Product"
render={({ field, invalid }) =>
itemSelect(field.value as string, (id) => {
field.onChange(id)
if (!form.getValues('freeItemId')) form.setValue('freeItemId', id)
}, 'Select product…', invalid)
}
/>
<FormField
control={form.control}
name="triggerQty"
label="Quantity"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
</div>
<p className="text-sm font-medium text-foreground">…and gets free</p>
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<FormField
control={form.control}
name="freeItemId"
label="Free product"
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
/>
<FormField
control={form.control}
name="freeQty"
label="Free quantity"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
</div>
</div>
)}
{/* ── Combo gift ─────────────────────────────────────────────── */}
{ruleType === 'FreeBundle' && (
<div className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">The store must buy all of these</p>
<Button type="button" variant="outline" size="sm" onClick={() => bundle.append({ itemId: '', qty: 1 })}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add product
</Button>
</div>
{bundle.fields.map((row, index) => (
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_7rem_auto]">
<FormField
control={form.control}
name={`bundle.${index}.itemId`}
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
/>
<FormField
control={form.control}
name={`bundle.${index}.qty`}
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={bundle.fields.length === 1}
onClick={() => bundle.remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</div>
))}
{form.formState.errors.bundle?.message && (
<p className="text-xs text-destructive">{form.formState.errors.bundle.message}</p>
)}
<p className="text-sm font-medium text-foreground">…and gets this free</p>
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<FormField
control={form.control}
name="freeItemId"
label="Free product"
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
/>
<FormField
control={form.control}
name="freeQty"
label="Free quantity"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
</div>
</div>
)}
{/* ── Flat discount ──────────────────────────────────────────── */}
{ruleType === 'FlatPercent' && (
<div className="rounded-xl border p-4">
<FormField
control={form.control}
name="discountPercent"
label="Discount on the whole order (%)"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
/>
</div>
)}
{/* ── Spend more, save more ──────────────────────────────────── */}
{ruleType === 'SlabDiscount' && (
<div className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Discount levels</p>
<Button type="button" variant="outline" size="sm" onClick={() => tiers.append({ minOrderAmount: 0, discountPercent: 0 })}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add level
</Button>
</div>
{tiers.fields.map((row, index) => (
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_1fr_auto]">
<FormField
control={form.control}
name={`tiers.${index}.minOrderAmount`}
label={index === 0 ? 'When the order reaches (Rs.)' : undefined}
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 0 })}
/>
<FormField
control={form.control}
name={`tiers.${index}.discountPercent`}
label={index === 0 ? 'Discount (%)' : undefined}
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
/>
<Button
type="button"
variant="outline"
size="sm"
className={index === 0 ? 'mt-6' : ''}
disabled={tiers.fields.length === 1}
onClick={() => tiers.remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</div>
))}
{form.formState.errors.tiers?.message && (
<p className="text-xs text-destructive">{form.formState.errors.tiers.message}</p>
)}
</div>
)}
<div className="rounded-xl bg-muted/50 px-4 py-3 text-sm">
<span className="font-medium text-foreground">Summary: </span>
<span className="text-muted-foreground">{preview}</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="dateRangeFrom"
label="Starts on"
required
render={({ field, invalid }) => (
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
)}
/>
<FormField
control={form.control}
name="dateRangeTo"
label="Ends on"
description="The offer still applies on this day."
required
render={({ field, invalid }) => (
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
)}
/>
</div>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="isActive" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
Active
</label>
</div>
)}
/>
<div className="flex flex-col gap-3 rounded-xl border p-4">
<p className="text-sm font-medium text-foreground">Where does it apply? <span className="font-normal text-muted-foreground">(optional)</span></p>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="regionId"
label="Region"
description="Leave on All Regions to offer it everywhere."
render={({ field }) => (
<Select value={field.value as string} onValueChange={(x) => field.onChange(x ?? '')}>
<SelectTrigger>
<SelectValue placeholder="All Regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Regions</SelectItem>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="eligibleSegment"
label="Store segment"
description="Optional, e.g. Gold. Empty = every store."
render={({ field }) => (
<Input
name={field.name}
ref={field.ref}
value={(field.value as string) ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="All segments"
/>
)}
/>
</div>
</div>
</FieldGroup>
</fieldset>
{!readOnly && <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
</Button>
</div>}
</form>
</div>
)
}
@@ -1,61 +0,0 @@
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
// Plain-language names for the four rule types, so users don't need to know the technical names.
export const RULE_META: Record<SchemeRuleType, { label: string; description: string; example: string }> = {
BuyXGetY: {
label: 'Buy & get free',
description: 'The store buys a quantity of a product and gets free units.',
example: 'Buy 10 Soap, get 1 Soap free',
},
FreeBundle: {
label: 'Combo gift',
description: 'The store buys a set of products together and gets a free gift.',
example: 'Buy Soap + Shampoo, get a Toothbrush free',
},
FlatPercent: {
label: 'Flat discount',
description: 'A fixed percentage off the whole order.',
example: '10% off every order',
},
SlabDiscount: {
label: 'Spend more, save more',
description: 'The discount grows as the order value reaches higher levels.',
example: '5% off above Rs. 5,000, 8% off above Rs. 10,000',
},
}
export interface SlabTierConfig {
minOrderAmount: number
discountPercent: number
}
export function parseRuleConfig(scheme: Pick<SchemeResponse, 'ruleType' | 'ruleConfig'>) {
try {
const cfg = scheme.ruleConfig ? JSON.parse(scheme.ruleConfig) : null
if (scheme.ruleType === 'FlatPercent') return { discountPercent: Number(cfg?.discountPercent) || 0, tiers: [] as SlabTierConfig[] }
if (scheme.ruleType === 'SlabDiscount') return { discountPercent: 0, tiers: (cfg?.tiers ?? []) as SlabTierConfig[] }
} catch {
// Malformed config — fall through to empty.
}
return { discountPercent: 0, tiers: [] as SlabTierConfig[] }
}
const money = (n: number) => `Rs. ${n.toLocaleString()}`
/** One-sentence description of what a scheme does, built from its saved rule. */
export function describeScheme(scheme: SchemeResponse): string {
switch (scheme.ruleType) {
case 'BuyXGetY':
return `Buy ${scheme.triggerQty ?? '?'} ${scheme.triggerItemName ?? 'item'}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
case 'FreeBundle': {
const parts = scheme.bundleComponents.map((c) => `${c.qty} ${c.itemName}`).join(' + ') || 'items'
return `Buy ${parts}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
}
case 'FlatPercent':
return `${parseRuleConfig(scheme).discountPercent}% off the order`
case 'SlabDiscount': {
const tiers = [...parseRuleConfig(scheme).tiers].sort((a, b) => a.minOrderAmount - b.minOrderAmount)
return tiers.length ? tiers.map((t) => `${t.discountPercent}% off from ${money(t.minOrderAmount)}`).join(', ') : 'No levels set'
}
}
}
@@ -0,0 +1,265 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SchemeResponse, RegionResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
regionId: z.string().optional(),
dateRangeFrom: z.string().min(1, 'Required'),
dateRangeTo: z.string().min(1, 'Required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface SchemeSheetProps {
open: boolean
onClose: () => void
scheme: SchemeResponse | null
onSuccess: () => void
}
export function SchemeSheet({ open, onClose, scheme, onSuccess }: SchemeSheetProps) {
const isEdit = !!scheme
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
ruleType: 'FlatPercent',
regionId: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
},
})
useEffect(() => {
if (open) {
form.reset(
scheme
? {
name: scheme.name,
ruleType: scheme.ruleType,
regionId: scheme.regionId ?? '',
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
dateRangeTo: scheme.dateRangeTo.split('T')[0],
isActive: scheme.isActive,
}
: {
name: '',
ruleType: 'FlatPercent',
regionId: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
}
)
}
}, [open, scheme])
useEffect(() => {
if (open) {
regionsApi.list({ pageSize: 100 })
.then((res) => setRegionsList(getPaginatedItems(res)))
.catch(() => {
toast.add({ title: 'Failed to load regions', type: 'error' })
})
}
}, [open])
async function onSubmit(values: FormValues) {
try {
const body = {
name: values.name,
ruleType: values.ruleType,
regionId: values.regionId || null,
dateRangeFrom: values.dateRangeFrom,
dateRangeTo: values.dateRangeTo,
isActive: values.isActive,
}
if (isEdit) {
await schemesApi.update(scheme!.id, body)
toast.add({ title: 'Scheme updated', type: 'success' })
} else {
await schemesApi.create(body)
toast.add({ title: 'Scheme created', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Scheme' : 'Add Scheme'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Name"
required
render={({ field, invalid }) => (
<Input
id="name"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Buy 2 Get 1 Free"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ruleType"
label="Rule Type"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select rule type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="BuyXGetY">BuyXGetY</SelectItem>
<SelectItem value="SlabDiscount">SlabDiscount</SelectItem>
<SelectItem value="FlatPercent">FlatPercent</SelectItem>
<SelectItem value="FreeBundle">FreeBundle</SelectItem>
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="regionId"
label="Region"
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="All Regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Regions</SelectItem>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="dateRangeFrom"
label="Date Range From"
required
render={({ field, invalid }) => (
<Input
id="dateRangeFrom"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="dateRangeTo"
label="Date Range To"
required
render={({ field, invalid }) => (
<Input
id="dateRangeTo"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
Active
</label>
</div>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,87 @@
'use client'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { SchemeResponse } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface SchemeViewSheetProps {
open: boolean
onClose: () => void
scheme: SchemeResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) {
if (!scheme) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Scheme Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="Name" value={scheme.name} />
<Row label="Rule Type" value={<Badge>{scheme.ruleType}</Badge>} />
<Row label="Region" value={scheme.regionName ?? 'All Regions'} />
<Row label="Eligible Segment" value={scheme.eligibleSegment ?? 'All Segments'} />
<Row label="Status" value={<Badge variant={scheme.isActive ? 'default' : 'secondary'}>{scheme.isActive ? 'Active' : 'Inactive'}</Badge>} />
<Row label="Date Range" value={`${fmtDate(scheme.dateRangeFrom)} → ${fmtDate(scheme.dateRangeTo)}`} />
<Row label="Agent Limit Exempt" value={scheme.agentLimitExempt ? 'Yes' : 'No'} />
{scheme.baselineExpectedSales > 0 && (
<Row label="Baseline Expected Sales" value={`Rs. ${scheme.baselineExpectedSales.toLocaleString()}`} />
)}
{scheme.ruleConfig && <Row label="Rule Config" value={<span className="font-mono text-xs">{scheme.ruleConfig}</span>} />}
{scheme.triggerItemName && (
<Row label="Trigger" value={`${scheme.triggerQty} × ${scheme.triggerItemName}`} />
)}
{scheme.freeItemName && (
<Row label="Free Goods" value={`${scheme.freeQty} × ${scheme.freeItemName}`} />
)}
<Row label="Created" value={fmtDate(scheme.createdAt)} />
</div>
{scheme.bundleComponents.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium text-foreground">Bundle Components</p>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="px-3 py-2 text-right font-medium">Qty</th>
</tr>
</thead>
<tbody>
{scheme.bundleComponents.map((c) => (
<tr key={c.itemId} className="border-t">
<td className="px-3 py-2">{c.itemName} <span className="ml-1 text-xs text-muted-foreground">({c.sku})</span></td>
<td className="px-3 py-2 text-right">{c.qty}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -1,7 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -13,7 +12,8 @@ import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
import { RULE_META, describeScheme } from './scheme-labels'
import { SchemeSheet } from './scheme-sheet'
import { SchemeViewSheet } from './scheme-view-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -26,11 +26,14 @@ const RULE_TYPE_VARIANT: Record<SchemeRuleType, 'default' | 'secondary'> = {
}
export function SchemesClient() {
const router = useRouter()
const [data, setData] = useState<SchemeResponse[]>([])
const [loading, setLoading] = useState(true)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<SchemeResponse | null>(null)
const [viewing, setViewing] = useState<SchemeResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false)
const fetchData = useCallback(async () => {
setLoading(true)
@@ -49,15 +52,18 @@ export function SchemesClient() {
}, [fetchData])
function openCreate() {
router.push('/pricing/schemes/new')
setEditing(null)
setSheetOpen(true)
}
function openEdit(scheme: SchemeResponse) {
router.push(`/pricing/schemes/${scheme.id}/edit`)
setEditing(scheme)
setSheetOpen(true)
}
function openView(scheme: SchemeResponse) {
router.push(`/pricing/schemes/${scheme.id}`)
setViewing(scheme)
setViewOpen(true)
}
async function handleDelete(scheme: SchemeResponse) {
@@ -85,14 +91,11 @@ export function SchemesClient() {
},
{
key: 'ruleType',
title: 'Offer',
title: 'Rule Type',
fields: ['ruleType'],
minWidth: 280,
width: 160,
cellRender: (row) => (
<div className="flex flex-col gap-1">
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]} className="w-fit">{RULE_META[row.ruleType].label}</Badge>
<span className="text-sm text-muted-foreground">{describeScheme(row)}</span>
</div>
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
),
},
{
@@ -174,6 +177,18 @@ export function SchemesClient() {
}}
/>
<SchemeSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
scheme={editing}
onSuccess={fetchData}
/>
<SchemeViewSheet
open={viewOpen}
onClose={() => setViewOpen(false)}
scheme={viewing}
/>
</>
)
}
@@ -1,5 +0,0 @@
import { SchemeForm } from '../_components/scheme-form'
export default function NewSchemePage() {
return <SchemeForm scheme={null} />
}
@@ -0,0 +1,205 @@
'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>
)
}
@@ -0,0 +1,153 @@
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]))]
}
}
}
@@ -0,0 +1,5 @@
import { FinanceReportClient } from './_components/finance-report-client'
export default function FinanceReportPage() {
return <FinanceReportClient />
}
@@ -24,14 +24,10 @@ 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'
import { FreeIssuePanel, type FreeLine } from '../../_components/free-issue-panel'
const lineItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
@@ -60,13 +56,6 @@ 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)
const [freeLines, setFreeLines] = useState<FreeLine[]>([])
const [discountIds, setDiscountIds] = useState<string[]>([])
useEffect(() => {
invoiceCreationModeApi.get().then(setMode).catch(() => {})
}, [])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -94,21 +83,9 @@ export default function EditShopPOPage() {
setUsersList(users)
form.reset({
items: poRes.items
.filter((i) => i.isActive && !i.isFree)
.filter((i) => i.isActive)
.map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice })),
})
setDiscountIds(poRes.discounts.map((d) => d.schemeId))
setFreeLines(
poRes.items
.filter((i) => i.isActive && i.isFree && i.schemeId)
.map((i) => ({
itemId: i.itemId,
itemName: i.itemName,
quantity: i.quantity,
schemeId: i.schemeId!,
schemeName: i.schemeName ?? 'Scheme',
}))
)
})
.catch((err) => {
toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' })
@@ -121,14 +98,9 @@ export default function EditShopPOPage() {
if (!po || !confirmedByUserId) return
setConfirming(true)
try {
const updated = mode === 'ModeA'
? await shopPosApi.confirmAndCreateInvoice(po.id, confirmedByUserId)
: await shopPosApi.confirm(po.id, confirmedByUserId)
const updated = await shopPosApi.confirm(po.id, confirmedByUserId)
setPo(updated)
toast.add({
title: mode === 'ModeA' ? 'Shop PO confirmed and invoice created' : 'Shop PO confirmed',
type: 'success',
})
toast.add({ title: 'Shop PO confirmed', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
} finally {
@@ -153,13 +125,7 @@ export default function EditShopPOPage() {
async function onSubmit(values: FormValues) {
try {
await shopPosApi.updateItems(id, {
discountSchemeIds: discountIds,
items: [
...values.items,
...freeLines.map((l) => ({ itemId: l.itemId, quantity: l.quantity, isFree: true, schemeId: l.schemeId })),
],
})
await shopPosApi.updateItems(id, { items: values.items })
toast.add({ title: 'Shop PO updated', type: 'success' })
router.push('/sales')
} catch (err) {
@@ -317,20 +283,6 @@ export default function EditShopPOPage() {
</div>
</div>
<FreeIssuePanel
storeId={po.storeId}
paidLines={watchedItems.map((l) => ({
itemId: l.itemId,
quantity: Number(l.quantity) || 0,
unitPrice: Number(l.unitPrice) || 0,
}))}
freeLines={freeLines}
onChange={setFreeLines}
discountIds={discountIds}
onDiscountChange={setDiscountIds}
disabled={locked}
/>
<div className="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3>
@@ -83,7 +83,7 @@ export default function ShopPOViewPage() {
if (!po) return null
const activeItems = po.items.filter((i) => i.isActive).sort((a, b) => Number(a.isFree) - Number(b.isFree))
const activeItems = po.items.filter((i) => i.isActive)
return (
<div className="flex flex-col gap-6">
@@ -155,7 +155,6 @@ export default function ShopPOViewPage() {
<tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
<th className="w-28 px-3 py-2 text-right font-medium">Quantity</th>
<th className="w-28 px-3 py-2 text-right font-medium">Free Issue</th>
<th className="w-28 px-3 py-2 text-right font-medium">Fulfilled</th>
<th className="w-32 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>
@@ -164,21 +163,15 @@ export default function ShopPOViewPage() {
<tbody>
{activeItems.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">
No items on this PO.
</td>
</tr>
) : (
activeItems.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">
{item.itemName}
{item.isFree && item.schemeName && (
<Badge variant="outline" className="ml-2">{item.schemeName}</Badge>
)}
</td>
<td className="px-3 py-2 text-right">{item.isFree ? '-' : item.quantity}</td>
<td className="px-3 py-2 text-right">{item.isFree ? item.quantity : '-'}</td>
<td className="px-3 py-2">{item.itemName}</td>
<td className="px-3 py-2 text-right">{item.quantity}</td>
<td className="px-3 py-2 text-right">{item.quantityFulfilled}</td>
<td className="px-3 py-2 text-right">{fmtAmount(item.unitPrice)}</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
@@ -188,14 +181,8 @@ export default function ShopPOViewPage() {
</tbody>
{activeItems.length > 0 && (
<tfoot>
{po.discounts.map((d) => (
<tr key={d.schemeId} className="border-t bg-muted/30">
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Scheme discount ({d.schemeName})</td>
<td className="px-3 py-2 text-right text-sm font-medium text-foreground">-{fmtAmount(d.amount)}</td>
</tr>
))}
<tr className="border-t bg-muted/30">
<td colSpan={5} className="px-3 py-3 text-right text-sm font-medium text-foreground">
<td colSpan={4} className="px-3 py-3 text-right text-sm font-medium text-foreground">
Total
</td>
<td className="px-3 py-3 text-right text-base font-semibold text-foreground">

Some files were not shown because too many files have changed in this diff Show More