Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c775146b64 | |||
| 6e4bebb89b | |||
| 932d09c135 | |||
| 455ce699ad | |||
| 7d7e9f1318 | |||
| 87e3112a9a | |||
| 801276559d | |||
| b1bda1448e | |||
| 58247a1a0b | |||
| 3a503abe6a | |||
| 855eb44118 | |||
| 33dc4adce2 | |||
| 5b2b39b7bf | |||
| e13ec0f109 | |||
| 2c39b34c5f | |||
| 99832df134 | |||
| 2be9d9b396 | |||
| a12e170205 | |||
| fdfca7cf5e | |||
| e84ce29064 | |||
| 2be2928ad1 | |||
| 872fd42c4a | |||
| 39ad424d21 | |||
| d79c5e55d0 | |||
| 50d3113c28 | |||
| 34d33571de | |||
| 0f575370d4 | |||
| e4061fa94c | |||
| 5f8bc70174 | |||
| f2d5c9f50a | |||
| 9e39981c9a | |||
| bd8b2d8b44 | |||
| f26557a66e | |||
| 91959913a8 | |||
| 48f607d623 | |||
| 82be251878 | |||
| 209ee7da2d | |||
| 6b9f27573b | |||
| a0df2a1bbf | |||
| d1c3d4e22f | |||
| a80512994b | |||
| 2de9e5e4ad |
@@ -0,0 +1,209 @@
|
|||||||
|
using Distribution.Features.Notifications;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Distribution.Tests.Services;
|
||||||
|
|
||||||
|
public class NotificationServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<INotificationRepository> _repo = new();
|
||||||
|
private readonly Mock<IUnitOfWork> _uow = new();
|
||||||
|
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
|
||||||
|
private readonly Mock<IHubClients> _clients = new();
|
||||||
|
private readonly Mock<IClientProxy> _proxy = new();
|
||||||
|
private readonly NotificationService _sut;
|
||||||
|
|
||||||
|
public NotificationServiceTests()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||||
|
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
|
||||||
|
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
|
||||||
|
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
|
||||||
|
|
||||||
|
_sut = new NotificationService(
|
||||||
|
_repo.Object,
|
||||||
|
new NotificationRecipientResolver(_repo.Object),
|
||||||
|
_hub.Object,
|
||||||
|
_uow.Object,
|
||||||
|
NullLogger<NotificationService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationArgs Args(Guid? actor = null) =>
|
||||||
|
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
|
||||||
|
|
||||||
|
// Registry
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryNotificationTypeConstant_HasADefinition()
|
||||||
|
{
|
||||||
|
var constants = typeof(NotificationTypes)
|
||||||
|
.GetFields()
|
||||||
|
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||||
|
.Select(f => (string)f.GetRawConstantValue()!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(constants);
|
||||||
|
Assert.Equal(constants.Count, constants.Distinct().Count());
|
||||||
|
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
|
||||||
|
{
|
||||||
|
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
|
||||||
|
{
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_ExcludesTheActingUser()
|
||||||
|
{
|
||||||
|
var actor = Guid.NewGuid();
|
||||||
|
var other = Guid.NewGuid();
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
||||||
|
[RecipientRule.Users(actor, other)], Args(actor));
|
||||||
|
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
var recipient = Assert.Single(saved!.Recipients);
|
||||||
|
Assert.Equal(other, recipient.FK_UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
|
||||||
|
{
|
||||||
|
var a = Guid.NewGuid();
|
||||||
|
var b = Guid.NewGuid();
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
|
||||||
|
|
||||||
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||||
|
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_clients.Verify(c => c.User(a.ToString()), Times.Once);
|
||||||
|
_clients.Verify(c => c.User(b.ToString()), Times.Once);
|
||||||
|
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
|
||||||
|
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
|
||||||
|
{
|
||||||
|
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(() =>
|
||||||
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
|
||||||
|
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
|
||||||
|
{
|
||||||
|
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("socket gone"));
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(() =>
|
||||||
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient rules
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
|
||||||
|
{
|
||||||
|
var both = Guid.NewGuid();
|
||||||
|
var permissionOnly = Guid.NewGuid();
|
||||||
|
var scopeOnly = Guid.NewGuid();
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
|
||||||
|
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
|
||||||
|
|
||||||
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||||
|
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
|
||||||
|
|
||||||
|
Assert.Equal([both], ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
|
||||||
|
{
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
var supervisor = Guid.NewGuid();
|
||||||
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
|
||||||
|
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
|
||||||
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
|
||||||
|
{
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
var owner = Guid.NewGuid();
|
||||||
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
|
||||||
|
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||||
|
|
||||||
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
|
||||||
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
|
||||||
|
{
|
||||||
|
Models.Notification? saved = null;
|
||||||
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
||||||
|
.Callback<Models.Notification>(n => saved = n)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
|
||||||
|
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
|
||||||
|
|
||||||
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_DeduplicatesUsersAcrossRules()
|
||||||
|
{
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
|
||||||
|
|
||||||
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
||||||
|
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
|
||||||
|
|
||||||
|
Assert.Equal([user], ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
public DbSet<Invoice> Invoices { get; set; }
|
public DbSet<Invoice> Invoices { get; set; }
|
||||||
|
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||||||
|
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
|
||||||
|
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
|
||||||
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
@@ -66,12 +69,18 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||||
public DbSet<SysConfig> SysConfigs { get; set; }
|
public DbSet<SysConfig> SysConfigs { get; set; }
|
||||||
public DbSet<BankAccount> BankAccounts { get; set; }
|
public DbSet<BankAccount> BankAccounts { get; set; }
|
||||||
public DbSet<GlPostingLog> GlPostingLogs { get; set; }
|
|
||||||
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
||||||
public DbSet<ChequePage> ChequePages { get; set; }
|
public DbSet<ChequePage> ChequePages { get; set; }
|
||||||
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
||||||
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
||||||
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
||||||
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
|
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
|
||||||
|
public DbSet<ExpenseCategory> ExpenseCategories { get; set; }
|
||||||
|
public DbSet<ExpenseVendor> ExpenseVendors { get; set; }
|
||||||
|
public DbSet<Expense> Expenses { get; set; }
|
||||||
|
public DbSet<ExpenseAttachment> ExpenseAttachments { get; set; }
|
||||||
|
public DbSet<RecurringExpenseTemplate> RecurringExpenseTemplates { get; set; }
|
||||||
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
@@ -174,6 +183,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ShopPOId)
|
.HasForeignKey(i => i.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.Invoice)
|
||||||
|
.WithMany(inv => inv.Items)
|
||||||
|
.HasForeignKey(i => i.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.ShopPOItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasOne(v => v.Invoice)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(v => v.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.InvoiceVersion)
|
||||||
|
.WithMany(v => v.Items)
|
||||||
|
.HasForeignKey(i => i.FK_InvoiceVersionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.ShopPOItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Invoice>()
|
modelBuilder.Entity<Invoice>()
|
||||||
.HasMany(i => i.Payments)
|
.HasMany(i => i.Payments)
|
||||||
.WithOne(p => p.Invoice)
|
.WithOne(p => p.Invoice)
|
||||||
@@ -583,16 +640,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(r => r.FK_RegionId)
|
.HasIndex(r => r.FK_RegionId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
// --- GL posting log ---
|
|
||||||
|
|
||||||
modelBuilder.Entity<GlPostingLog>()
|
|
||||||
.HasIndex(l => new { l.Event, l.SourceId })
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<GlPostingLog>()
|
|
||||||
.Property(l => l.Event)
|
|
||||||
.HasMaxLength(60);
|
|
||||||
|
|
||||||
// --- SysConfig ---
|
// --- SysConfig ---
|
||||||
|
|
||||||
modelBuilder.Entity<SysConfig>()
|
modelBuilder.Entity<SysConfig>()
|
||||||
@@ -719,6 +766,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<SupplierPaymentGRN>()
|
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||||
.HasIndex(a => a.FK_GoodsReceiptId);
|
.HasIndex(a => a.FK_GoodsReceiptId);
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<Notification>()
|
||||||
|
.HasIndex(n => new { n.EntityType, n.EntityId });
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasOne(r => r.Notification)
|
||||||
|
.WithMany(n => n.Recipients)
|
||||||
|
.HasForeignKey(r => r.FK_NotificationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasOne(r => r.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FK_UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasIndex(r => new { r.FK_UserId, r.IsRead });
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
// --- StoreCreditMovement ---
|
// --- StoreCreditMovement ---
|
||||||
|
|
||||||
modelBuilder.Entity<StoreCreditMovement>()
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
@@ -747,7 +818,88 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<StoreCreditMovement>()
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
|
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
|
||||||
|
|
||||||
|
// --- Expense ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.HasOne(c => c.Parent)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_ParentCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.Property(c => c.Name).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.Property(c => c.Code).HasMaxLength(50);
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.HasIndex(c => new { c.FK_ParentCategoryId, c.Name }).IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseVendor>()
|
||||||
|
.Property(v => v.Name).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<ExpenseVendor>()
|
||||||
|
.HasIndex(v => v.Name).IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Category).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Warehouse).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Vendor).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_VendorId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.BankAccount).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_BankAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.ChequePage).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_ChequePageId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.RecurringTemplate).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_RecurringTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.Property(e => e.Payee).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.Property(e => e.BillRef).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasIndex(e => new { e.Status, e.BillDate });
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.HasOne(a => a.Expense).WithMany(e => e.Attachments)
|
||||||
|
.HasForeignKey(a => a.FK_ExpenseId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.OriginalFileName).HasMaxLength(260);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.StoredPath).HasMaxLength(500);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.ContentType).HasMaxLength(100);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Category).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Warehouse).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Vendor).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_VendorId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.Property(t => t.Name).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasIndex(t => new { t.IsPaused, t.NextRunDate });
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
|
Seed.ExpenseSeed.Apply(modelBuilder);
|
||||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||||
Seed.OperationsDataSeed.Apply(modelBuilder);
|
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||||
Seed.ProcurementSeed.Apply(modelBuilder);
|
Seed.ProcurementSeed.Apply(modelBuilder);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class ExpenseSeed
|
||||||
|
{
|
||||||
|
private static readonly DateTime SeedDate = new(2026, 10, 21);
|
||||||
|
|
||||||
|
// Top-level categories
|
||||||
|
private static readonly Guid CatSalaries = new("30000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid CatUtilities = new("30000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid CatRent = new("30000000-0000-0000-0000-000000000003");
|
||||||
|
private static readonly Guid CatVehicle = new("30000000-0000-0000-0000-000000000004");
|
||||||
|
private static readonly Guid CatOffice = new("30000000-0000-0000-0000-000000000005");
|
||||||
|
private static readonly Guid CatBankFees = new("30000000-0000-0000-0000-000000000006");
|
||||||
|
private static readonly Guid CatMisc = new("30000000-0000-0000-0000-000000000007");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ExpenseCategory>().HasData(
|
||||||
|
// Top-level
|
||||||
|
new ExpenseCategory { Id = CatSalaries, Name = "Salaries", Code = "SAL", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatUtilities, Name = "Utilities", Code = "UTL", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatRent, Name = "Rent", Code = "RNT", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatVehicle, Name = "Vehicle", Code = "VEH", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatOffice, Name = "Office", Code = "OFF", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatBankFees, Name = "Bank Fees", Code = "BNK", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatMisc, Name = "Miscellaneous", Code = "MSC", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Salaries
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000101"), Name = "Basic Salary", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000102"), Name = "Bonus", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000103"), Name = "Overtime", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Utilities
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000201"), Name = "Electricity", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000202"), Name = "Water", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000203"), Name = "Internet", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Rent
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000301"), Name = "Warehouse Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000302"), Name = "Office Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Vehicle
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000401"), Name = "Fuel", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000402"), Name = "Maintenance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000403"), Name = "Insurance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Office
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000501"), Name = "Stationery", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000502"), Name = "Cleaning", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Bank Fees
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000601"), Name = "Service Charges", FK_ParentCategoryId = CatBankFees, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Misc
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000701"), Name = "Other", FK_ParentCategoryId = CatMisc, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,8 +68,13 @@ public static class RoleAndPermissionSeed
|
|||||||
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
||||||
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||||
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||||
private static readonly Guid PermFinanceReportView = new("20000000-0000-0000-0000-000000000051");
|
private static readonly Guid PermExpenseView = new("20000000-0000-0000-0000-000000000051");
|
||||||
private static readonly Guid PermGlManage = new("20000000-0000-0000-0000-000000000052");
|
private static readonly Guid PermExpenseCreate = new("20000000-0000-0000-0000-000000000052");
|
||||||
|
private static readonly Guid PermExpensePost = new("20000000-0000-0000-0000-000000000053");
|
||||||
|
private static readonly Guid PermExpensePay = new("20000000-0000-0000-0000-000000000054");
|
||||||
|
private static readonly Guid PermExpenseVoid = new("20000000-0000-0000-0000-000000000055");
|
||||||
|
private static readonly Guid PermExpenseManageCats = new("20000000-0000-0000-0000-000000000056");
|
||||||
|
private static readonly Guid PermExpenseManageRec = new("20000000-0000-0000-0000-000000000057");
|
||||||
|
|
||||||
public static void Apply(ModelBuilder modelBuilder)
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -137,8 +142,13 @@ public static class RoleAndPermissionSeed
|
|||||||
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Permission { Id = PermFinanceReportView, Code = "finance-reports.view", Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)", CreatedAt = SeedDate, IsActive = true },
|
new Permission { Id = PermExpenseView, Code = "expense.view", Description = "View expenses, categories, vendors and recurring templates", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Permission { Id = PermGlManage, Code = "gl.manage", Description = "Set up the General Ledger link and retry failed postings", CreatedAt = SeedDate, IsActive = true }
|
new Permission { Id = PermExpenseCreate, Code = "expense.create", Description = "Create and edit draft expenses", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpensePost, Code = "expense.post", Description = "Post a draft expense (approve it)", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpensePay, Code = "expense.pay", Description = "Pay a posted expense (debit a bank account or issue a cheque)", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseVoid, Code = "expense.void", Description = "Void an expense", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseManageCats, Code = "expense.manage_categories", Description = "Manage expense categories, titles and vendors", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseManageRec, Code = "expense.manage_recurring", Description = "Manage recurring expense templates", CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// SuperAdmin gets every permission
|
// SuperAdmin gets every permission
|
||||||
@@ -196,8 +206,13 @@ public static class RoleAndPermissionSeed
|
|||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermFinanceReportView, CreatedAt = SeedDate, IsActive = true },
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseView, CreatedAt = SeedDate, IsActive = true },
|
||||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGlManage, CreatedAt = SeedDate, IsActive = true }
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseCreate, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePost, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePay, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseVoid, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageCats, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageRec, CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
@@ -8,8 +7,7 @@ namespace Distribution.Features.ChequeBook;
|
|||||||
public class ChequeBookService(
|
public class ChequeBookService(
|
||||||
IChequeBookRepository repo,
|
IChequeBookRepository repo,
|
||||||
IBankAccountRepository bankAccountRepo,
|
IBankAccountRepository bankAccountRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private const string DefaultCurrency = "LKR";
|
private const string DefaultCurrency = "LKR";
|
||||||
|
|
||||||
@@ -173,7 +171,6 @@ public class ChequeBookService(
|
|||||||
|
|
||||||
repo.UpdatePage(page);
|
repo.UpdatePage(page);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
await gl.PostIssuedChequeAsync(page, request.Action, ct);
|
|
||||||
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,27 @@ namespace Distribution.Features.DashboardReport;
|
|||||||
|
|
||||||
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
||||||
|
|
||||||
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
|
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||||
|
|
||||||
public record SalesKpis(
|
public record SalesKpis(
|
||||||
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
|
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||||
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||||
|
|
||||||
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
||||||
|
|
||||||
|
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
|
||||||
|
|
||||||
|
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
|
||||||
|
|
||||||
|
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
|
||||||
|
|
||||||
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
||||||
|
|
||||||
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
||||||
|
|
||||||
public record SalesSummaryResponse(
|
public record SalesSummaryResponse(
|
||||||
DateTime From, DateTime To, string Granularity,
|
DateTime From, DateTime To, string Granularity,
|
||||||
SalesKpiComparison Kpis, List<TrendPoint> Trend,
|
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
|
||||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||||
|
|
||||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||||
|
|||||||
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
public Guid RepId { get; set; }
|
public Guid RepId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
|
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
|
||||||
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||||
|
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
|
||||||
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
||||||
{
|
{
|
||||||
var start = f.From.Date;
|
var start = f.From.Date;
|
||||||
var end = f.To.Date.AddDays(1);
|
var end = f.To.Date.AddDays(1);
|
||||||
var q =
|
var q =
|
||||||
from li in db.ShopPOItems.AsNoTracking()
|
from li in db.InvoiceItems.AsNoTracking()
|
||||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
where li.IsActive && !li.IsRemoved
|
||||||
where inv.Status == InvoiceStatus.Finalized
|
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
|
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
|
||||||
select new SoldLine
|
select new SoldLine
|
||||||
{
|
{
|
||||||
Date = inv.FinalizedAt!.Value.Date,
|
Date = li.Invoice.FinalizedAt!.Value.Date,
|
||||||
PoId = li.FK_ShopPOId,
|
PoId = li.Invoice.FK_ShopPOId,
|
||||||
Value = li.QuantityFulfilled * li.UnitPrice,
|
Value = li.Quantity * li.UnitPrice,
|
||||||
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
|
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
|
||||||
CategoryId = li.Item.FK_CategoryId,
|
CategoryId = li.Item.FK_CategoryId,
|
||||||
CategoryName = li.Item.Category.Name,
|
CategoryName = li.Item.Category.Name,
|
||||||
RegionId = li.ShopPO.Store.Route.FK_RegionId,
|
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
|
||||||
RegionName = li.ShopPO.Store.Route.Region.Name,
|
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
|
||||||
RepId = li.ShopPO.RaisedById,
|
RepId = li.Invoice.ShopPO.RaisedById,
|
||||||
};
|
};
|
||||||
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
||||||
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
||||||
@@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
{
|
{
|
||||||
var start = f.From.Date;
|
var start = f.From.Date;
|
||||||
var end = f.To.Date.AddDays(1);
|
var end = f.To.Date.AddDays(1);
|
||||||
|
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
|
||||||
var q = db.SaleReturnItems.AsNoTracking()
|
var q = db.SaleReturnItems.AsNoTracking()
|
||||||
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
|
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
|
||||||
|
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
|
||||||
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
return q;
|
return q;
|
||||||
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
|||||||
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||||
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
||||||
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
||||||
return new SalesTotals(gross, returns, orders, costedSales, cost);
|
return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
|
||||||
|
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
|
||||||
|
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
if (f.CategoryId.HasValue) return 0m;
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Invoices.AsNoTracking()
|
||||||
|
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
|
||||||
|
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
|
||||||
|
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
|
||||||
|
private const int OverdueDays = 30;
|
||||||
|
|
||||||
|
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
|
||||||
|
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
|
||||||
|
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||||
|
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
{
|
||||||
|
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
|
|
||||||
|
var rows = await invs.Select(i => new
|
||||||
|
{
|
||||||
|
i.FinalizedAt,
|
||||||
|
i.TotalAmount,
|
||||||
|
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||||
|
.Sum(p => (decimal?)p.Amount) ?? 0m,
|
||||||
|
}).ToListAsync();
|
||||||
|
|
||||||
|
var balances = rows
|
||||||
|
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
|
||||||
|
.ToList();
|
||||||
|
return new ReceivablesTotals(collected,
|
||||||
|
balances.Sum(b => b.Balance),
|
||||||
|
balances.Where(b => b.Due < end).Sum(b => b.Balance));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
|
|||||||
public class DashboardReportService(IDashboardReportRepository repo)
|
public class DashboardReportService(IDashboardReportRepository repo)
|
||||||
{
|
{
|
||||||
public const int MaxRangeDays = 366;
|
public const int MaxRangeDays = 366;
|
||||||
private const int DailyGranularityMaxDays = 62;
|
private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
|
||||||
|
|
||||||
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
||||||
{
|
{
|
||||||
@@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
|
|
||||||
private static SalesKpis ToKpis(SalesTotals t)
|
private static SalesKpis ToKpis(SalesTotals t)
|
||||||
{
|
{
|
||||||
var net = t.GrossSales - t.ReturnValue;
|
var net = t.GrossSales - t.ReturnValue - t.Discounts;
|
||||||
var gp = t.CostedSales - t.CostOfSales;
|
var gp = t.CostedSales - t.CostOfSales;
|
||||||
return new SalesKpis(
|
return new SalesKpis(
|
||||||
t.GrossSales, t.ReturnValue, net, t.Orders,
|
t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
|
||||||
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
||||||
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
|
||||||
|
new(r.Collected, r.Outstanding, r.Overdue,
|
||||||
|
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
|
||||||
|
|
||||||
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||||
{
|
{
|
||||||
var days = (f.To - f.From).Days + 1;
|
var days = (f.To - f.From).Days + 1;
|
||||||
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
|
|
||||||
var current = await repo.GetSalesTotalsAsync(f);
|
var current = await repo.GetSalesTotalsAsync(f);
|
||||||
var previous = await repo.GetSalesTotalsAsync(prev);
|
var previous = await repo.GetSalesTotalsAsync(prev);
|
||||||
|
var currentCash = await repo.GetReceivablesAsync(f);
|
||||||
|
var previousCash = await repo.GetReceivablesAsync(prev);
|
||||||
var sales = await repo.GetDailySalesAsync(f);
|
var sales = await repo.GetDailySalesAsync(f);
|
||||||
var returns = await repo.GetDailyReturnsAsync(f);
|
var returns = await repo.GetDailyReturnsAsync(f);
|
||||||
|
|
||||||
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
|||||||
return new SalesSummaryResponse(
|
return new SalesSummaryResponse(
|
||||||
f.From, f.To, daily ? "day" : "month",
|
f.From, f.To, daily ? "day" : "month",
|
||||||
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
||||||
|
new CashKpiComparison(
|
||||||
|
ToCash(currentCash, current.GrossSales - current.Discounts),
|
||||||
|
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
|
||||||
trend,
|
trend,
|
||||||
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||||
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
|||||||
public interface IDashboardReportRepository
|
public interface IDashboardReportRepository
|
||||||
{
|
{
|
||||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||||
|
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
|
||||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.LoadingEvent;
|
using Distribution.Features.LoadingEvent;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
|
|||||||
IShopPORepository shopPORepo,
|
IShopPORepository shopPORepo,
|
||||||
ILoadingSheetRepository loadingSheetRepo,
|
ILoadingSheetRepository loadingSheetRepo,
|
||||||
StockMover mover,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string DeliveriesLink = "/operations/deliveries";
|
||||||
|
|
||||||
|
// Route owner + managers whose scope covers the store being delivered to.
|
||||||
|
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
|
||||||
|
[
|
||||||
|
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
|
||||||
|
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
|
||||||
|
];
|
||||||
|
|
||||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
new(t.Id,
|
new(t.Id,
|
||||||
t.FK_RideId,
|
t.FK_RideId,
|
||||||
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (!fullyDelivered && shopPO.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskPartiallyDelivered,
|
||||||
|
DeliveryRecipients(shopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
|
||||||
|
ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(taskId);
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (task.ShopPO?.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskFailed,
|
||||||
|
DeliveryRecipients(task.ShopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
|
||||||
|
ct);
|
||||||
|
|
||||||
return ToResponse(task);
|
return ToResponse(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class ExpenseAttachmentController(ExpenseAttachmentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("api/expenses/{expenseId:guid}/attachments")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<IActionResult> Upload(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UploadAsync(expenseId, file, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/expense-attachments/{id:guid}/download")]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.DownloadAsync(id, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.Stream, result.Value.ContentType, result.Value.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("api/expense-attachments/{id:guid}")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||||
|
await service.DeleteAsync(id, ct) ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseAttachmentService(
|
||||||
|
AppDBContext context,
|
||||||
|
IConfiguration config,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private const long MaxBytes = 10 * 1024 * 1024;
|
||||||
|
private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
|
||||||
|
private static readonly string[] AllowedContentTypes =
|
||||||
|
["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
private string AttachmentsRoot =>
|
||||||
|
config["Expense:AttachmentsRoot"]
|
||||||
|
?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured");
|
||||||
|
|
||||||
|
public async Task<ExpenseAttachmentResponse?> UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct);
|
||||||
|
if (expense is null) return null;
|
||||||
|
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
throw new InvalidOperationException("File is empty");
|
||||||
|
if (file.Length > MaxBytes)
|
||||||
|
throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit");
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
if (!AllowedExtensions.Contains(ext))
|
||||||
|
throw new InvalidOperationException($"File type '{ext}' is not allowed");
|
||||||
|
if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
|
||||||
|
throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2"));
|
||||||
|
var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir);
|
||||||
|
Directory.CreateDirectory(absoluteDir);
|
||||||
|
|
||||||
|
var storedName = $"{Guid.NewGuid()}{ext}";
|
||||||
|
var absolutePath = Path.Combine(absoluteDir, storedName);
|
||||||
|
|
||||||
|
await using (var stream = File.Create(absolutePath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachment = new Models.ExpenseAttachment
|
||||||
|
{
|
||||||
|
FK_ExpenseId = expenseId,
|
||||||
|
OriginalFileName = file.FileName,
|
||||||
|
StoredPath = absolutePath,
|
||||||
|
ContentType = file.ContentType,
|
||||||
|
SizeBytes = file.Length,
|
||||||
|
UploadedByUserId = userId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.ExpenseAttachments.AddAsync(attachment, ct);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
try { File.Delete(absolutePath); } catch { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName,
|
||||||
|
attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||||
|
if (a is null) return null;
|
||||||
|
if (!File.Exists(a.StoredPath)) return null;
|
||||||
|
|
||||||
|
var stream = File.OpenRead(a.StoredPath);
|
||||||
|
return (stream, a.ContentType, a.OriginalFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid attachmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||||
|
if (a is null) return false;
|
||||||
|
|
||||||
|
a.IsActive = false;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
// Best-effort file removal — keep DB soft-delete even if the file is gone.
|
||||||
|
try { File.Delete(a.StoredPath); } catch { }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expenses")]
|
||||||
|
public class ExpenseController(
|
||||||
|
ExpenseService service,
|
||||||
|
IPermissionService permissions,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
private async Task<bool> HasAsync(string code)
|
||||||
|
{
|
||||||
|
if (currentUser.RoleId is not { } roleId) return false;
|
||||||
|
return await permissions.HasPermissionAsync(roleId, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] ExpenseStatus? status,
|
||||||
|
[FromQuery] Guid? categoryId,
|
||||||
|
[FromQuery] Guid? warehouseId,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20) =>
|
||||||
|
Ok(await service.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create endpoint takes a required ?action= query to drive the 4 form buttons:
|
||||||
|
/// draft → Save draft; post → Save & post (requires expense.post); pay → Save & pay
|
||||||
|
/// (requires expense.post + expense.pay). Draft is the default.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> Create(
|
||||||
|
CreateExpenseRequest request,
|
||||||
|
[FromQuery] ExpenseAction action = ExpenseAction.Draft,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (action is ExpenseAction.Post or ExpenseAction.Pay && !await HasAsync("expense.post"))
|
||||||
|
return Forbid();
|
||||||
|
if (action == ExpenseAction.Pay && !await HasAsync("expense.pay"))
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
|
try { return Ok(await service.CreateAsync(request, action, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> UpdateDraft(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateDraftAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/post")]
|
||||||
|
[Authorize("expense.post")]
|
||||||
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PostAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/pay")]
|
||||||
|
[Authorize("expense.pay")]
|
||||||
|
public async Task<IActionResult> Pay(Guid id, ExpensePaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PayAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/void")]
|
||||||
|
[Authorize("expense.void")]
|
||||||
|
public async Task<IActionResult> Void(Guid id, VoidExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.VoidAsync(id, request?.Reason, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public record ExpensePaymentRequest(
|
||||||
|
PaymentMethod Method,
|
||||||
|
Guid? BankAccountId,
|
||||||
|
Guid? ChequePageId);
|
||||||
|
|
||||||
|
public record CreateExpenseRequest(
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description,
|
||||||
|
/// <summary>Required when action = Pay. Ignored for Draft / Post.</summary>
|
||||||
|
ExpensePaymentRequest? Payment);
|
||||||
|
|
||||||
|
public record UpdateExpenseDraftRequest(
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
|
public record VoidExpenseRequest(string? Reason);
|
||||||
|
|
||||||
|
public record ExpenseAttachmentResponse(
|
||||||
|
Guid Id,
|
||||||
|
string OriginalFileName,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record ExpenseResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid CategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
Guid? ParentCategoryId,
|
||||||
|
string? ParentCategoryName,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
string? WarehouseName,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? VendorName,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description,
|
||||||
|
ExpenseStatus Status,
|
||||||
|
PaymentMethod? Method,
|
||||||
|
Guid? BankAccountId,
|
||||||
|
Guid? ChequePageId,
|
||||||
|
string? ChequeNo,
|
||||||
|
DateTime? PaidAt,
|
||||||
|
Guid RecordedByUserId,
|
||||||
|
Guid? PostedByUserId,
|
||||||
|
Guid? PaidByUserId,
|
||||||
|
Guid? VoidedByUserId,
|
||||||
|
string? VoidReason,
|
||||||
|
Guid? RecurringTemplateId,
|
||||||
|
List<ExpenseAttachmentResponse> Attachments,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PagedExpensesResponse(List<ExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseRepository(AppDBContext context) : IExpenseRepository
|
||||||
|
{
|
||||||
|
private IQueryable<Models.Expense> Base() =>
|
||||||
|
context.Expenses
|
||||||
|
.Include(e => e.Category!).ThenInclude(c => c.Parent)
|
||||||
|
.Include(e => e.Warehouse)
|
||||||
|
.Include(e => e.Vendor)
|
||||||
|
.Include(e => e.ChequePage)
|
||||||
|
.Include(e => e.Attachments.Where(a => a.IsActive));
|
||||||
|
|
||||||
|
public async Task<Models.Expense?> GetByIdAsync(Guid id) =>
|
||||||
|
await Base().FirstOrDefaultAsync(e => e.Id == id && e.IsActive);
|
||||||
|
|
||||||
|
private IQueryable<Models.Expense> Filtered(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||||
|
Base()
|
||||||
|
.Where(e => e.IsActive)
|
||||||
|
.Where(e => !status.HasValue || e.Status == status)
|
||||||
|
.Where(e => !categoryId.HasValue
|
||||||
|
|| e.FK_CategoryId == categoryId
|
||||||
|
|| e.Category!.FK_ParentCategoryId == categoryId)
|
||||||
|
.Where(e => !warehouseId.HasValue || e.FK_WarehouseId == warehouseId)
|
||||||
|
.Where(e => !from.HasValue || e.BillDate >= from)
|
||||||
|
.Where(e => !to.HasValue || e.BillDate <= to);
|
||||||
|
|
||||||
|
public async Task<List<Models.Expense>> ListAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||||
|
int page, int pageSize) =>
|
||||||
|
await Filtered(status, categoryId, warehouseId, from, to)
|
||||||
|
.OrderByDescending(e => e.BillDate)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||||
|
await Filtered(status, categoryId, warehouseId, from, to).CountAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Expense entity) =>
|
||||||
|
await context.Expenses.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.Expense entity) =>
|
||||||
|
context.Expenses.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Features.BankAccount;
|
||||||
|
using Distribution.Features.ChequeBook;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseService(
|
||||||
|
AppDBContext context,
|
||||||
|
IExpenseRepository repo,
|
||||||
|
IBankAccountRepository bankAccountRepo,
|
||||||
|
IChequeBookRepository chequeBookRepo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) =>
|
||||||
|
new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt);
|
||||||
|
|
||||||
|
private static ExpenseResponse ToResponse(Models.Expense e) =>
|
||||||
|
new(e.Id,
|
||||||
|
e.FK_CategoryId, e.Category?.Name ?? string.Empty,
|
||||||
|
e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name,
|
||||||
|
e.FK_WarehouseId, e.Warehouse?.Name,
|
||||||
|
e.Amount, e.BillDate, e.DueDate, e.PeriodCovered,
|
||||||
|
e.FK_VendorId, e.Vendor?.Name,
|
||||||
|
e.Payee, e.BillRef, e.Description,
|
||||||
|
e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo,
|
||||||
|
e.PaidAt,
|
||||||
|
e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason,
|
||||||
|
e.FK_RecurringTemplateId,
|
||||||
|
e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(),
|
||||||
|
e.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedExpensesResponse> ListAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||||
|
int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||||
|
|
||||||
|
var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize);
|
||||||
|
var total = await repo.CountAsync(status, categoryId, warehouseId, from, to);
|
||||||
|
return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> GetAsync(Guid id)
|
||||||
|
{
|
||||||
|
var e = await repo.GetByIdAsync(id);
|
||||||
|
return e is null ? null : ToResponse(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse> CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ValidateBase(request);
|
||||||
|
|
||||||
|
var category = await context.ExpenseCategories.Include(c => c.Parent)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||||
|
?? throw new InvalidOperationException("Category not found");
|
||||||
|
if (category.FK_ParentCategoryId is null)
|
||||||
|
throw new InvalidOperationException("Pick a specific title under a category, not the category itself");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var entity = new Models.Expense
|
||||||
|
{
|
||||||
|
FK_CategoryId = request.CategoryId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
BillDate = request.BillDate.Date,
|
||||||
|
DueDate = request.DueDate?.Date,
|
||||||
|
PeriodCovered = request.PeriodCovered?.Date,
|
||||||
|
FK_VendorId = request.VendorId,
|
||||||
|
Payee = request.Payee,
|
||||||
|
BillRef = request.BillRef,
|
||||||
|
Description = request.Description,
|
||||||
|
RecordedByUserId = userId,
|
||||||
|
Status = ExpenseStatus.Draft
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(entity);
|
||||||
|
|
||||||
|
if (action is ExpenseAction.Post or ExpenseAction.Pay)
|
||||||
|
{
|
||||||
|
entity.Status = ExpenseStatus.Posted;
|
||||||
|
entity.PostedByUserId = userId;
|
||||||
|
entity.PostedAt = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == ExpenseAction.Pay)
|
||||||
|
{
|
||||||
|
if (request.Payment is null)
|
||||||
|
throw new InvalidOperationException("Payment details are required when action = Pay");
|
||||||
|
await ApplyPaymentAsync(entity, request.Payment, userId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(entity.Id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Only Draft expenses can be edited");
|
||||||
|
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
|
||||||
|
entity.FK_CategoryId = request.CategoryId;
|
||||||
|
entity.FK_WarehouseId = request.WarehouseId;
|
||||||
|
entity.Amount = request.Amount;
|
||||||
|
entity.BillDate = request.BillDate.Date;
|
||||||
|
entity.DueDate = request.DueDate?.Date;
|
||||||
|
entity.PeriodCovered = request.PeriodCovered?.Date;
|
||||||
|
entity.FK_VendorId = request.VendorId;
|
||||||
|
entity.Payee = request.Payee;
|
||||||
|
entity.BillRef = request.BillRef;
|
||||||
|
entity.Description = request.Description;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> PostAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Only Draft expenses can be posted");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
entity.Status = ExpenseStatus.Posted;
|
||||||
|
entity.PostedByUserId = userId;
|
||||||
|
entity.PostedAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Posted)
|
||||||
|
throw new InvalidOperationException("Only Posted expenses can be paid");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyPaymentAsync(entity, payment, userId, ct);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> VoidAsync(Guid id, string? reason, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status == ExpenseStatus.Void)
|
||||||
|
throw new InvalidOperationException("Already voided");
|
||||||
|
if (entity.Status == ExpenseStatus.Paid)
|
||||||
|
throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
entity.Status = ExpenseStatus.Void;
|
||||||
|
entity.VoidedByUserId = userId;
|
||||||
|
entity.VoidedAt = DateTime.Now;
|
||||||
|
entity.VoidReason = reason;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void ValidateBase(CreateExpenseRequest request)
|
||||||
|
{
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date)
|
||||||
|
throw new InvalidOperationException("Due date cannot be earlier than bill date");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account);
|
||||||
|
/// Cheque → consume an Unused ChequePage from an Active cheque book.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (payment.Method == PaymentMethod.Credit)
|
||||||
|
throw new InvalidOperationException("Credit is not a valid method for expense payments");
|
||||||
|
|
||||||
|
if (payment.Method == PaymentMethod.Cash)
|
||||||
|
{
|
||||||
|
if (payment.BankAccountId is null)
|
||||||
|
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||||
|
|
||||||
|
var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value)
|
||||||
|
?? throw new InvalidOperationException("BankAccountId does not reference a valid account");
|
||||||
|
if (account.IsActive is false)
|
||||||
|
throw new InvalidOperationException("The selected account is inactive");
|
||||||
|
|
||||||
|
entity.FK_BankAccountId = account.Id;
|
||||||
|
}
|
||||||
|
else // Cheque
|
||||||
|
{
|
||||||
|
if (payment.ChequePageId is null)
|
||||||
|
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
|
||||||
|
|
||||||
|
var page = await context.ChequePages
|
||||||
|
.Include(p => p.ChequeBook)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == payment.ChequePageId.Value, ct)
|
||||||
|
?? throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
|
||||||
|
if (page.IssueStatus != ChequePageIssueStatus.Unused)
|
||||||
|
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
|
||||||
|
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
|
||||||
|
throw new InvalidOperationException("The cheque book is not active");
|
||||||
|
|
||||||
|
page.IssueStatus = ChequePageIssueStatus.Issued;
|
||||||
|
page.PayeeType = PayeeType.Other;
|
||||||
|
page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense";
|
||||||
|
page.IssueDate = DateTime.Now;
|
||||||
|
page.Amount = entity.Amount;
|
||||||
|
page.CurrencyCode = "LKR";
|
||||||
|
page.PrintedAt = DateTime.Now;
|
||||||
|
|
||||||
|
chequeBookRepo.UpdatePage(page);
|
||||||
|
entity.FK_ChequePageId = page.Id;
|
||||||
|
entity.ChequePage = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Method = payment.Method;
|
||||||
|
entity.Status = ExpenseStatus.Paid;
|
||||||
|
entity.PaidByUserId = userId;
|
||||||
|
entity.PaidAt = DateTime.Now;
|
||||||
|
|
||||||
|
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||||
|
// Debit Expense (category)
|
||||||
|
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public interface IExpenseRepository
|
||||||
|
{
|
||||||
|
Task<Models.Expense?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Expense>> ListAsync(
|
||||||
|
ExpenseStatus? status,
|
||||||
|
Guid? categoryId,
|
||||||
|
Guid? warehouseId,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
int page,
|
||||||
|
int pageSize);
|
||||||
|
Task<int> CountAsync(
|
||||||
|
ExpenseStatus? status,
|
||||||
|
Guid? categoryId,
|
||||||
|
Guid? warehouseId,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to);
|
||||||
|
Task AddAsync(Models.Expense entity);
|
||||||
|
void Update(Models.Expense entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-categories")]
|
||||||
|
public class ExpenseCategoryController(ExpenseCategoryService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Tree() => Ok(await service.GetTreeAsync());
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Create(CreateExpenseCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public record ExpenseCategoryResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
Guid? ParentCategoryId,
|
||||||
|
bool IsActive,
|
||||||
|
List<ExpenseCategoryResponse> Children);
|
||||||
|
|
||||||
|
public record CreateExpenseCategoryRequest(
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
Guid? ParentCategoryId);
|
||||||
|
|
||||||
|
public record UpdateExpenseCategoryRequest(
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public class ExpenseCategoryRepository(AppDBContext context) : IExpenseCategoryRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.ExpenseCategory?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.ExpenseCategory>> GetAllAsync() =>
|
||||||
|
await context.ExpenseCategories.OrderBy(c => c.FK_ParentCategoryId).ThenBy(c => c.Name).ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.ExpenseCategory entity) =>
|
||||||
|
await context.ExpenseCategories.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.ExpenseCategory entity) =>
|
||||||
|
context.ExpenseCategories.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public class ExpenseCategoryService(IExpenseCategoryRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
public async Task<List<ExpenseCategoryResponse>> GetTreeAsync()
|
||||||
|
{
|
||||||
|
var all = await repo.GetAllAsync();
|
||||||
|
var byParent = all.ToLookup(c => c.FK_ParentCategoryId);
|
||||||
|
|
||||||
|
return all
|
||||||
|
.Where(c => c.FK_ParentCategoryId is null)
|
||||||
|
.Select(c => Map(c, byParent))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseCategoryResponse Map(Models.ExpenseCategory c, ILookup<Guid?, Models.ExpenseCategory> byParent) =>
|
||||||
|
new(c.Id, c.Name, c.Code, c.FK_ParentCategoryId, c.IsActive,
|
||||||
|
byParent[c.Id].Select(child => Map(child, byParent)).ToList());
|
||||||
|
|
||||||
|
public async Task<ExpenseCategoryResponse> CreateAsync(CreateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
throw new InvalidOperationException("Name is required");
|
||||||
|
|
||||||
|
if (request.ParentCategoryId is { } parentId)
|
||||||
|
{
|
||||||
|
var parent = await repo.GetByIdAsync(parentId)
|
||||||
|
?? throw new InvalidOperationException("Parent category not found");
|
||||||
|
if (parent.FK_ParentCategoryId is not null)
|
||||||
|
throw new InvalidOperationException("Only two levels are allowed — cannot nest under a title");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new Models.ExpenseCategory
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim(),
|
||||||
|
FK_ParentCategoryId = request.ParentCategoryId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseCategoryResponse?> UpdateAsync(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
entity.Name = request.Name.Trim();
|
||||||
|
entity.Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim();
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public interface IExpenseCategoryRepository
|
||||||
|
{
|
||||||
|
Task<Models.ExpenseCategory?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.ExpenseCategory>> GetAllAsync();
|
||||||
|
Task AddAsync(Models.ExpenseCategory entity);
|
||||||
|
void Update(Models.ExpenseCategory entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-vendors")]
|
||||||
|
public class ExpenseVendorController(ExpenseVendorService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50) =>
|
||||||
|
Ok(await service.ListAsync(search, page, pageSize));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Create(CreateExpenseVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public record ExpenseVendorResponse(Guid Id, string Name, string? ContactInfo, bool IsActive);
|
||||||
|
|
||||||
|
public record CreateExpenseVendorRequest(string Name, string? ContactInfo);
|
||||||
|
|
||||||
|
public record UpdateExpenseVendorRequest(string Name, string? ContactInfo, bool IsActive);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public class ExpenseVendorRepository(AppDBContext context) : IExpenseVendorRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.ExpenseVendor?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.ExpenseVendors.FirstOrDefaultAsync(v => v.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize) =>
|
||||||
|
await context.ExpenseVendors
|
||||||
|
.Where(v => v.IsActive && (search == null || v.Name.Contains(search)))
|
||||||
|
.OrderBy(v => v.Name)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync(string? search) =>
|
||||||
|
await context.ExpenseVendors
|
||||||
|
.CountAsync(v => v.IsActive && (search == null || v.Name.Contains(search)));
|
||||||
|
|
||||||
|
public async Task<bool> NameExistsAsync(string name, Guid? excludeId) =>
|
||||||
|
await context.ExpenseVendors.AnyAsync(v => v.Name == name && (excludeId == null || v.Id != excludeId));
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.ExpenseVendor entity) =>
|
||||||
|
await context.ExpenseVendors.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.ExpenseVendor entity) =>
|
||||||
|
context.ExpenseVendors.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public record PagedExpenseVendorsResponse(List<ExpenseVendorResponse> Items, int Page, int PageSize, int Total);
|
||||||
|
|
||||||
|
public class ExpenseVendorService(IExpenseVendorRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static ExpenseVendorResponse Map(Models.ExpenseVendor v) =>
|
||||||
|
new(v.Id, v.Name, v.ContactInfo, v.IsActive);
|
||||||
|
|
||||||
|
public async Task<PagedExpenseVendorsResponse> ListAsync(string? search, int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 50;
|
||||||
|
|
||||||
|
search = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
|
||||||
|
var items = await repo.ListAsync(search, page, pageSize);
|
||||||
|
var total = await repo.CountAsync(search);
|
||||||
|
return new PagedExpenseVendorsResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseVendorResponse> CreateAsync(CreateExpenseVendorRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var name = (request.Name ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||||
|
if (await repo.NameExistsAsync(name, null))
|
||||||
|
throw new InvalidOperationException("A vendor with this name already exists");
|
||||||
|
|
||||||
|
var entity = new Models.ExpenseVendor { Name = name, ContactInfo = request.ContactInfo };
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseVendorResponse?> UpdateAsync(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
var name = (request.Name ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||||
|
if (await repo.NameExistsAsync(name, id))
|
||||||
|
throw new InvalidOperationException("A vendor with this name already exists");
|
||||||
|
|
||||||
|
entity.Name = name;
|
||||||
|
entity.ContactInfo = request.ContactInfo;
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public interface IExpenseVendorRepository
|
||||||
|
{
|
||||||
|
Task<Models.ExpenseVendor?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize);
|
||||||
|
Task<int> CountAsync(string? search);
|
||||||
|
Task<bool> NameExistsAsync(string name, Guid? excludeId);
|
||||||
|
Task AddAsync(Models.ExpenseVendor entity);
|
||||||
|
void Update(Models.ExpenseVendor entity);
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.FinanceReport;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Proxies the General Ledger service's reports (TrialBalance, GeneralLedger, BalanceSheet, ProfitAndLoss, CashFlow,
|
|
||||||
/// BudgetVsActual, TaxSummary). Query parameters are forwarded as-is; Json is returned as the GL data payload and
|
|
||||||
/// Pdf/Csv are returned as downloadable files.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/reports/finance")]
|
|
||||||
public class FinanceReportController(IGeneralLedgerClient gl) : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>Active GL accounts (code + name) for the report filters' account dropdown.</summary>
|
|
||||||
[HttpGet("accounts")]
|
|
||||||
[Authorize("finance-reports.view")]
|
|
||||||
public async Task<IActionResult> Accounts(CancellationToken ct)
|
|
||||||
{
|
|
||||||
GeneralLedgerResult result;
|
|
||||||
try { result = await gl.SendAsync(HttpMethod.Get, "v1/accounts?isActive=true", null, ct); }
|
|
||||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
|
||||||
|
|
||||||
if (!result.Success || GlJson.Data(result) is not { } data)
|
|
||||||
return StatusCode(502, new { error = GlJson.Message(result) ?? "Could not load GL accounts." });
|
|
||||||
|
|
||||||
var items = GlJson.Get(data, "items");
|
|
||||||
var accounts = items is { ValueKind: JsonValueKind.Array }
|
|
||||||
? items.Value.EnumerateArray()
|
|
||||||
.Select(a => new { accountCode = GlJson.GetString(a, "accountCode") ?? "", accountName = GlJson.GetString(a, "accountName") ?? "" })
|
|
||||||
.OrderBy(a => a.accountCode, StringComparer.Ordinal)
|
|
||||||
.ToList()
|
|
||||||
: [];
|
|
||||||
return Ok(accounts);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{reportType}")]
|
|
||||||
[Authorize("finance-reports.view")]
|
|
||||||
public async Task<IActionResult> Get(string reportType, [FromQuery] string outputFormat = "Json", CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var query = new StringBuilder($"v1/reports?reportType={Uri.EscapeDataString(reportType)}&outputFormat={Uri.EscapeDataString(outputFormat)}");
|
|
||||||
foreach (var (key, values) in Request.Query)
|
|
||||||
{
|
|
||||||
if (key is "reportType" or "outputFormat") continue;
|
|
||||||
foreach (var v in values)
|
|
||||||
query.Append('&').Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(v ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
GeneralLedgerResult result;
|
|
||||||
try { result = await gl.SendAsync(HttpMethod.Get, query.ToString(), null, ct); }
|
|
||||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
|
||||||
|
|
||||||
if (!result.Success)
|
|
||||||
return StatusCode(result.StatusCode is >= 400 and < 500 ? result.StatusCode : 502,
|
|
||||||
new { error = GlJson.Message(result) ?? "The General Ledger service rejected the request." });
|
|
||||||
|
|
||||||
var data = GlJson.Data(result);
|
|
||||||
if (data is null) return StatusCode(502, new { error = "The General Ledger service returned no data." });
|
|
||||||
|
|
||||||
if (!outputFormat.Equals("Json", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var base64 = GlJson.GetString(data.Value, "contentBase64");
|
|
||||||
if (base64 is null) return StatusCode(502, new { error = "The General Ledger service returned no file." });
|
|
||||||
return File(Convert.FromBase64String(base64),
|
|
||||||
GlJson.GetString(data.Value, "contentType") ?? "application/octet-stream",
|
|
||||||
GlJson.GetString(data.Value, "fileName"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Content(data.Value.GetRawText(), "application/json");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GeneralLedgerResult(int StatusCode, bool Success, JsonElement? Body);
|
|
||||||
|
|
||||||
public interface IGeneralLedgerClient
|
|
||||||
{
|
|
||||||
Task<bool> IsHealthyAsync(CancellationToken ct = default);
|
|
||||||
Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service-to-service client for the General Ledger microservice. The GL service is not user-facing:
|
|
||||||
/// every request carries the shared X-Api-Key (GeneralLedger:ApiKey, must equal the GL service's ApiKey:Value).
|
|
||||||
/// <paramref name="path"/> is relative to GeneralLedger:BaseUrl and carries the API version (e.g. "v1/accounts", "v1/journal-entries"), so moving a call to v2 is a one-path change.
|
|
||||||
/// </summary>
|
|
||||||
public class GeneralLedgerClient(HttpClient http) : IGeneralLedgerClient
|
|
||||||
{
|
|
||||||
public async Task<bool> IsHealthyAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var response = await http.GetAsync("/health", ct);
|
|
||||||
return response.IsSuccessStatusCode;
|
|
||||||
}
|
|
||||||
catch (HttpRequestException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
|
||||||
if (payload is not null) request.Content = JsonContent.Create(payload);
|
|
||||||
|
|
||||||
using var response = await http.SendAsync(request, ct);
|
|
||||||
JsonElement? body = null;
|
|
||||||
if (response.Content.Headers.ContentLength != 0)
|
|
||||||
{
|
|
||||||
try { body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct); }
|
|
||||||
catch (JsonException) { }
|
|
||||||
}
|
|
||||||
return new GeneralLedgerResult((int)response.StatusCode, response.IsSuccessStatusCode, body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
using Distribution.Data;
|
|
||||||
using Distribution.Infra.Authorization;
|
|
||||||
using Distribution.Models;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GlSetupRequest(int? Year);
|
|
||||||
|
|
||||||
public record YearEndCloseRequest(int Year);
|
|
||||||
|
|
||||||
public record OpeningBalanceRequest(DateOnly Date, string? Narration, List<OpeningBalanceLine> Lines);
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/gl")]
|
|
||||||
public class GlController(
|
|
||||||
GlSetupService setup,
|
|
||||||
GlPostingService posting,
|
|
||||||
IGeneralLedgerClient gl,
|
|
||||||
Distribution.Features.SysConfig.ISysConfigRepository configRepo,
|
|
||||||
AppDBContext context,
|
|
||||||
ICurrentUserService currentUser) : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpGet("status")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Status()
|
|
||||||
{
|
|
||||||
var mapping = new Dictionary<string, string?>();
|
|
||||||
foreach (var key in GlKeys.AllAccountKeys)
|
|
||||||
mapping[key] = (await configRepo.GetByKeyAsync(key))?.Value;
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
glReachable = await gl.IsHealthyAsync(),
|
|
||||||
setupDone = (await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value == "true",
|
|
||||||
openingBalancePosted = await context.GlPostingLogs.AnyAsync(l => l.Event == "OpeningBalance" && l.Status == GlPostingStatus.Posted),
|
|
||||||
closedYears = await posting.GetClosedYearsAsync(),
|
|
||||||
mapping,
|
|
||||||
posted = await context.GlPostingLogs.CountAsync(l => l.Status == GlPostingStatus.Posted),
|
|
||||||
failed = await context.GlPostingLogs.CountAsync(l => l.Status != GlPostingStatus.Posted)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("setup")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Setup(GlSetupRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try { return Ok(await setup.RunAsync(request.Year, currentUser.UserId, ct)); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("opening-balances")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> OpeningBalances(OpeningBalanceRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var row = await posting.PostOpeningBalanceAsync(request.Date, request.Narration, request.Lines, ct);
|
|
||||||
return row.Status == GlPostingStatus.Posted
|
|
||||||
? Ok(new { status = row.Status, glJournalNo = row.GlJournalNo })
|
|
||||||
: StatusCode(502, new { error = row.Error ?? "The General Ledger service did not accept the entry. It was saved and can be retried." });
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("year-end/preview")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> YearEndPreview([FromQuery] int year, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try { return Ok(await posting.GetYearEndPreviewAsync(year, ct)); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("year-end/close")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> YearEndClose(YearEndCloseRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try { return Ok(await posting.CloseYearAsync(request.Year, ct)); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("postings")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Postings([FromQuery] GlPostingStatus? status, [FromQuery] int take = 100)
|
|
||||||
{
|
|
||||||
var query = context.GlPostingLogs.AsQueryable();
|
|
||||||
if (status is not null) query = query.Where(l => l.Status == status);
|
|
||||||
var rows = await query.OrderByDescending(l => l.CreatedAt).Take(Math.Clamp(take, 1, 500))
|
|
||||||
.Select(l => new { l.Id, l.Event, l.SourceId, l.Reference, l.Status, l.GlJournalNo, l.Error, l.Attempts, l.LastAttemptAt, l.CreatedAt })
|
|
||||||
.ToListAsync();
|
|
||||||
return Ok(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("postings/retry")]
|
|
||||||
[Authorize("gl.manage")]
|
|
||||||
public async Task<IActionResult> Retry(CancellationToken ct) =>
|
|
||||||
Ok(new { posted = await posting.RetryFailedAsync(ct) });
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
/// <summary>Small readers for the GL service's ApiResponse envelope ({statusCode, success, message, data}).</summary>
|
|
||||||
public static class GlJson
|
|
||||||
{
|
|
||||||
public static JsonElement? Data(GeneralLedgerResult result) =>
|
|
||||||
result.Body is { ValueKind: JsonValueKind.Object } body && Get(body, "data") is { } data ? data : null;
|
|
||||||
|
|
||||||
public static string? Message(GeneralLedgerResult result) =>
|
|
||||||
result.Body is { ValueKind: JsonValueKind.Object } body ? Get(body, "message")?.GetString()
|
|
||||||
?? Get(body, "Message")?.GetString() : null;
|
|
||||||
|
|
||||||
/// <summary>Case-insensitive property lookup (GL success bodies are camelCase, its error bodies PascalCase).</summary>
|
|
||||||
public static JsonElement? Get(JsonElement element, string name)
|
|
||||||
{
|
|
||||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
|
||||||
foreach (var p in element.EnumerateObject())
|
|
||||||
if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) return p.Value;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? GetString(JsonElement element, string name)
|
|
||||||
{
|
|
||||||
var v = Get(element, name);
|
|
||||||
return v is null || v.Value.ValueKind == JsonValueKind.Null ? null
|
|
||||||
: v.Value.ValueKind == JsonValueKind.String ? v.Value.GetString() : v.Value.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static long? GetLong(JsonElement element, string name) =>
|
|
||||||
long.TryParse(GetString(element, name), out var n) ? n : null;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
/// <summary>SysConfig keys holding the GL account codes Distribution posts to (created by GlSetupService).</summary>
|
|
||||||
public static class GlKeys
|
|
||||||
{
|
|
||||||
public const string SetupDone = "Gl.Setup.Done";
|
|
||||||
|
|
||||||
public const string CashEquivalents = "Gl.Account.CashEquivalents"; // parent of Cash in Hand + Bank Accounts (GL cash root)
|
|
||||||
public const string CashInHand = "Gl.Account.CashInHand"; // parent of every Distribution Cash account
|
|
||||||
public const string BankAccounts = "Gl.Account.BankAccounts"; // parent of every Distribution Bank account
|
|
||||||
public const string ChequesInHand = "Gl.Account.ChequesInHand";
|
|
||||||
public const string Receivable = "Gl.Account.Receivable";
|
|
||||||
public const string Inventory = "Gl.Account.Inventory";
|
|
||||||
public const string InputVat = "Gl.Account.InputVat";
|
|
||||||
public const string InputNbt = "Gl.Account.InputNbt";
|
|
||||||
public const string Payable = "Gl.Account.Payable";
|
|
||||||
public const string ChequesIssued = "Gl.Account.ChequesIssued";
|
|
||||||
public const string Equity = "Gl.Account.Equity"; // header; parent of the equity accounts below
|
|
||||||
public const string ShareCapital = "Gl.Account.ShareCapital";
|
|
||||||
public const string RetainedEarnings = "Gl.Account.RetainedEarnings";
|
|
||||||
public const string Drawings = "Gl.Account.Drawings";
|
|
||||||
public const string GeneralReserve = "Gl.Account.GeneralReserve";
|
|
||||||
public const string RevaluationReserve = "Gl.Account.RevaluationReserve";
|
|
||||||
public const string Sales = "Gl.Account.Sales";
|
|
||||||
|
|
||||||
public static readonly string[] AllAccountKeys =
|
|
||||||
[
|
|
||||||
CashEquivalents, CashInHand, BankAccounts, ChequesInHand, Receivable, Inventory,
|
|
||||||
InputVat, InputNbt, Payable, ChequesIssued, Equity, ShareCapital, RetainedEarnings, Drawings,
|
|
||||||
GeneralReserve, RevaluationReserve, Sales
|
|
||||||
];
|
|
||||||
|
|
||||||
public static bool IsGlKey(string key) => key == SetupDone || AllAccountKeys.Contains(key);
|
|
||||||
}
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Distribution.Data;
|
|
||||||
using Distribution.Features.Grn;
|
|
||||||
using Distribution.Features.SysConfig;
|
|
||||||
using Distribution.Infra.UoW;
|
|
||||||
using Distribution.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GlLine(string AccountCode, decimal Debit, decimal Credit);
|
|
||||||
|
|
||||||
/// <summary>What closing a financial year would post. Closable is false with a Reason when it cannot be closed.</summary>
|
|
||||||
public record YearEndPreview(int Year, string StartDate, string EndDate, decimal TotalIncome, decimal TotalExpenses,
|
|
||||||
decimal NetProfit, bool Closable, string? Reason, bool AlreadyClosed);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One opening balance: Account is a key from OpeningAccounts (e.g. "Inventory", "ShareCapital") or "BankAccount"
|
|
||||||
/// with BankAccountId. Amount is the account's normal balance (>= 0); the debit/credit side comes from the account.
|
|
||||||
/// </summary>
|
|
||||||
public record OpeningBalanceLine(string Account, Guid? BankAccountId, decimal Amount);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mirrors Distribution business events into the General Ledger as journal entries. Every public Post* method is
|
|
||||||
/// called AFTER the business transaction has committed and never throws: a GL problem is recorded in GlPostingLogs
|
|
||||||
/// (status Failed, payload kept) and can be retried later, so it can never block a sale, payment or cheque action.
|
|
||||||
/// </summary>
|
|
||||||
public class GlPostingService(
|
|
||||||
IGeneralLedgerClient gl,
|
|
||||||
ISysConfigRepository configRepo,
|
|
||||||
GlSetupService setup,
|
|
||||||
AppDBContext context,
|
|
||||||
IUnitOfWork uow,
|
|
||||||
ILogger<GlPostingService> logger)
|
|
||||||
{
|
|
||||||
private record Payload(string EntryDate, string JournalType, string SourceModule, string Reference, string Narration,
|
|
||||||
string PreparedBy, List<PayloadLine> Lines);
|
|
||||||
|
|
||||||
private record PayloadLine(string AccountCode, decimal DebitAmount, decimal CreditAmount);
|
|
||||||
|
|
||||||
// ── Events ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public Task PostInvoiceFinalizedAsync(Models.Invoice invoice, CancellationToken ct = default) =>
|
|
||||||
PostAsync("InvoiceFinalized", invoice.Id, invoice.FinalizedAt ?? DateTime.Now, "INVOICE", $"Invoice {invoice.Id} finalized",
|
|
||||||
async () => invoice.TotalAmount <= 0 ? null :
|
|
||||||
[
|
|
||||||
new GlLine(await Acct(GlKeys.Receivable), invoice.TotalAmount, 0),
|
|
||||||
new GlLine(await Acct(GlKeys.Sales), 0, invoice.TotalAmount)
|
|
||||||
], ct);
|
|
||||||
|
|
||||||
public Task PostCustomerPaymentAsync(Models.Payment payment, CancellationToken ct = default) =>
|
|
||||||
PostAsync("CustomerPayment", payment.Id, payment.CollectedAt, "PAYMENT", $"Customer payment for invoice {payment.FK_InvoiceId}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
if (payment.Amount <= 0 || payment.Method == PaymentMethod.Credit) return null;
|
|
||||||
var debit = payment.Method == PaymentMethod.Cash
|
|
||||||
? await BankCode(payment.FK_BankAccountId)
|
|
||||||
: await Acct(GlKeys.ChequesInHand);
|
|
||||||
return [new GlLine(debit, payment.Amount, 0), new GlLine(await Acct(GlKeys.Receivable), 0, payment.Amount)];
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
/// <summary>Deposit / Return / Cancel of a received cheque that came from a customer payment.</summary>
|
|
||||||
public Task PostReceivedChequeAsync(Models.ReceivedCheque cheque, ReceivedChequeStatusAction action, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (action is not (ReceivedChequeStatusAction.Deposit or ReceivedChequeStatusAction.Return or ReceivedChequeStatusAction.Cancel))
|
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
var date = action == ReceivedChequeStatusAction.Deposit ? cheque.DepositDate ?? DateTime.Now : DateTime.Now;
|
|
||||||
return PostAsync($"ReceivedCheque{action}", cheque.Id, date, "CHEQUE", $"Received cheque {cheque.ChequeNo} {action}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
// Only cheques created by a customer payment were posted to Cheques-in-hand, so only they can move.
|
|
||||||
if (!await context.Payments.AnyAsync(p => p.FK_ReceivedChequeId == cheque.Id, ct)) return null;
|
|
||||||
var inHand = await Acct(GlKeys.ChequesInHand);
|
|
||||||
return action switch
|
|
||||||
{
|
|
||||||
ReceivedChequeStatusAction.Deposit =>
|
|
||||||
[new GlLine(await BankCode(cheque.FK_DepositBankAccountId), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)],
|
|
||||||
ReceivedChequeStatusAction.Return =>
|
|
||||||
[new GlLine(inHand, cheque.Amount, 0), new GlLine(await BankCode(cheque.FK_DepositBankAccountId), 0, cheque.Amount)],
|
|
||||||
_ => [new GlLine(await Acct(GlKeys.Receivable), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)]
|
|
||||||
};
|
|
||||||
}, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task PostGrnAsync(Models.GoodsReceipt grn, CancellationToken ct = default) =>
|
|
||||||
PostAsync("Grn", grn.Id, grn.ReceivedAt, "GRN", $"Goods received {grn.Id}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
decimal net = 0, nbt = 0, vat = 0;
|
|
||||||
foreach (var i in grn.Items.Where(i => i.IsActive))
|
|
||||||
{
|
|
||||||
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
|
|
||||||
net += a.Gross - a.Discount;
|
|
||||||
nbt += a.Nbt;
|
|
||||||
vat += a.Vat;
|
|
||||||
}
|
|
||||||
var total = net + nbt + vat;
|
|
||||||
if (total <= 0) return null;
|
|
||||||
|
|
||||||
var lines = new List<GlLine> { new(await Acct(GlKeys.Inventory), net, 0) };
|
|
||||||
if (nbt > 0) lines.Add(new GlLine(await Acct(GlKeys.InputNbt), nbt, 0));
|
|
||||||
if (vat > 0) lines.Add(new GlLine(await Acct(GlKeys.InputVat), vat, 0));
|
|
||||||
lines.Add(new GlLine(await Acct(GlKeys.Payable), 0, total));
|
|
||||||
return lines;
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
public Task PostSupplierPaymentAsync(Models.SupplierPayment payment, CancellationToken ct = default) =>
|
|
||||||
PostAsync("SupplierPayment", payment.Id, payment.PaidAt, "SUPPLIER_PAYMENT", $"Supplier payment {payment.Id}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
if (payment.Amount <= 0) return null;
|
|
||||||
var credit = payment.Method == PaymentMethod.Cash
|
|
||||||
? await BankCode(payment.FK_BankAccountId)
|
|
||||||
: await Acct(GlKeys.ChequesIssued);
|
|
||||||
return [new GlLine(await Acct(GlKeys.Payable), payment.Amount, 0), new GlLine(credit, 0, payment.Amount)];
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
/// <summary>Clear / Bounce / Cancel of a cheque issued through a supplier payment.</summary>
|
|
||||||
public Task PostIssuedChequeAsync(Models.ChequePage page, ChequePageStatusAction action, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (action == ChequePageStatusAction.Void) return Task.CompletedTask;
|
|
||||||
|
|
||||||
var date = action == ChequePageStatusAction.Clear ? page.ClearedDate ?? DateTime.Now : DateTime.Now;
|
|
||||||
return PostAsync($"IssuedCheque{action}", page.Id, date, "CHEQUE", $"Issued cheque {page.ChequeNo} {action}",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
var amount = page.Amount ?? 0;
|
|
||||||
if (amount <= 0 || !await context.SupplierPayments.AnyAsync(p => p.FK_ChequePageId == page.Id, ct)) return null;
|
|
||||||
var issued = await Acct(GlKeys.ChequesIssued);
|
|
||||||
if (action == ChequePageStatusAction.Clear)
|
|
||||||
{
|
|
||||||
var bookBankId = await context.ChequeBooks.Where(b => b.Id == page.FK_ChequeBookId)
|
|
||||||
.Select(b => (Guid?)b.FK_BankAccountId).FirstOrDefaultAsync(ct);
|
|
||||||
return [new GlLine(issued, amount, 0), new GlLine(await BankCode(bookBankId), 0, amount)];
|
|
||||||
}
|
|
||||||
// Bounce / Cancel: the cheque no longer settles the payable, so put it back.
|
|
||||||
return [new GlLine(issued, amount, 0), new GlLine(await Acct(GlKeys.Payable), 0, amount)];
|
|
||||||
}, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Posts the opening balances (asset side debited, liability/equity side credited). Zero amounts are skipped, so the
|
|
||||||
/// form can list every account with a default of 0. Reports the outcome so the caller can show a failure.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<GlPostingLog> PostOpeningBalanceAsync(DateOnly date, string? narration, List<OpeningBalanceLine> input, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
|
|
||||||
throw new InvalidOperationException("Run the General Ledger setup first.");
|
|
||||||
|
|
||||||
var lines = new List<GlLine>();
|
|
||||||
foreach (var l in input ?? [])
|
|
||||||
{
|
|
||||||
if (l.Amount < 0) throw new InvalidOperationException($"'{l.Account}': the amount cannot be negative.");
|
|
||||||
if (l.Amount == 0) continue;
|
|
||||||
|
|
||||||
if (l.Account == "BankAccount")
|
|
||||||
{
|
|
||||||
lines.Add(new GlLine(await BankCode(l.BankAccountId), l.Amount, 0));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!OpeningAccounts.TryGetValue(l.Account, out var def))
|
|
||||||
throw new InvalidOperationException($"Unknown account '{l.Account}'.");
|
|
||||||
var code = await Acct(def.Key);
|
|
||||||
lines.Add(def.DebitNormal ? new GlLine(code, l.Amount, 0) : new GlLine(code, 0, l.Amount));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.Count == 0)
|
|
||||||
throw new InvalidOperationException("Enter at least one opening balance greater than zero.");
|
|
||||||
|
|
||||||
var debit = lines.Sum(x => x.Debit);
|
|
||||||
var credit = lines.Sum(x => x.Credit);
|
|
||||||
if (debit != credit)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"The opening balances do not balance: assets (debits) total {debit:N2} but liabilities + equity (credits) total {credit:N2}. " +
|
|
||||||
"Adjust Share Capital / Retained Earnings so they match.");
|
|
||||||
|
|
||||||
var sourceId = Guid.NewGuid();
|
|
||||||
await PostAsync("OpeningBalance", sourceId, date.ToDateTime(TimeOnly.MinValue), "OPENING_BALANCE",
|
|
||||||
string.IsNullOrWhiteSpace(narration) ? "Opening balances" : narration.Trim(), () => Task.FromResult<List<GlLine>?>(lines), ct);
|
|
||||||
|
|
||||||
return await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == "OpeningBalance" && l.SourceId == sourceId, ct)
|
|
||||||
?? throw new InvalidOperationException("The opening balances could not be prepared. Check the server log.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Accounts that can carry an opening balance (besides individual cash/bank accounts) and their normal side.</summary>
|
|
||||||
public static readonly Dictionary<string, (string Key, bool DebitNormal)> OpeningAccounts = new()
|
|
||||||
{
|
|
||||||
["ChequesInHand"] = (GlKeys.ChequesInHand, true),
|
|
||||||
["Receivable"] = (GlKeys.Receivable, true),
|
|
||||||
["Inventory"] = (GlKeys.Inventory, true),
|
|
||||||
["InputVat"] = (GlKeys.InputVat, true),
|
|
||||||
["InputNbt"] = (GlKeys.InputNbt, true),
|
|
||||||
["Drawings"] = (GlKeys.Drawings, true),
|
|
||||||
["Payable"] = (GlKeys.Payable, false),
|
|
||||||
["ChequesIssued"] = (GlKeys.ChequesIssued, false),
|
|
||||||
["ShareCapital"] = (GlKeys.ShareCapital, false),
|
|
||||||
["RetainedEarnings"] = (GlKeys.RetainedEarnings, false),
|
|
||||||
["GeneralReserve"] = (GlKeys.GeneralReserve, false),
|
|
||||||
["RevaluationReserve"] = (GlKeys.RevaluationReserve, false),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Year-end close ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private const string YearEndEvent = "YearEndClose";
|
|
||||||
|
|
||||||
private static Guid YearEndSourceId(int year) =>
|
|
||||||
new(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes($"{YearEndEvent}:{year}")));
|
|
||||||
|
|
||||||
/// <summary>Years that have a posted closing journal.</summary>
|
|
||||||
public async Task<List<int>> GetClosedYearsAsync(CancellationToken ct = default) =>
|
|
||||||
(await context.GlPostingLogs
|
|
||||||
.Where(l => l.Event == YearEndEvent && l.Status == GlPostingStatus.Posted)
|
|
||||||
.Select(l => l.Reference)
|
|
||||||
.ToListAsync(ct))
|
|
||||||
.Select(r => int.TryParse(r.Split(':').Last(), out var y) ? y : 0)
|
|
||||||
.Where(y => y > 0).OrderBy(y => y).ToList();
|
|
||||||
|
|
||||||
private record CloseBuild(List<GlLine> Lines, decimal Income, decimal Expenses);
|
|
||||||
|
|
||||||
/// <summary>Reads the year's own P&L from GL (which excludes earlier closing journals) and builds the closing lines.</summary>
|
|
||||||
private async Task<CloseBuild> BuildYearEndCloseAsync(int year, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Get,
|
|
||||||
$"v1/reports?reportType=ProfitAndLoss&outputFormat=Json&periodStart={year}-01-01&periodEnd={year}-12-31", null, ct);
|
|
||||||
if (!result.Success || GlJson.Data(result) is not { } pl)
|
|
||||||
throw new InvalidOperationException($"Could not read the {year} profit and loss from the General Ledger: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
|
||||||
|
|
||||||
// Net CREDIT balance per account: income sections carry their amount as is, expense sections are reported
|
|
||||||
// as positive debits (so flip), unclassified rows are already credit - debit.
|
|
||||||
var netCredit = new Dictionary<string, decimal>();
|
|
||||||
decimal income = 0, expenses = 0;
|
|
||||||
void Collect(string section, int sign)
|
|
||||||
{
|
|
||||||
if (GlJson.Get(pl, section) is not { } s || GlJson.Get(s, "lines") is not { ValueKind: JsonValueKind.Array } sectionLines) return;
|
|
||||||
foreach (var l in sectionLines.EnumerateArray())
|
|
||||||
{
|
|
||||||
var code = GlJson.GetString(l, "accountCode");
|
|
||||||
if (code is null || !decimal.TryParse(GlJson.GetString(l, "amount"), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue;
|
|
||||||
var credit = sign * amount;
|
|
||||||
netCredit[code] = netCredit.GetValueOrDefault(code) + credit;
|
|
||||||
if (credit > 0) income += credit; else expenses += -credit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Collect("sales", 1); Collect("otherIncome", 1);
|
|
||||||
Collect("costOfSales", -1); Collect("distributionExpenses", -1); Collect("administrationExpenses", -1);
|
|
||||||
Collect("otherExpenses", -1); Collect("financialExpenses", -1);
|
|
||||||
Collect("unclassified", 1);
|
|
||||||
|
|
||||||
// Closing: an account with a net credit balance is debited, one with a net debit balance is credited.
|
|
||||||
var lines = netCredit.Where(kv => kv.Value != 0)
|
|
||||||
.Select(kv => kv.Value > 0 ? new GlLine(kv.Key, kv.Value, 0) : new GlLine(kv.Key, 0, -kv.Value)).ToList();
|
|
||||||
var debit = lines.Sum(x => x.Debit);
|
|
||||||
var credit2 = lines.Sum(x => x.Credit);
|
|
||||||
if (debit != credit2)
|
|
||||||
{
|
|
||||||
var retained = await Acct(GlKeys.RetainedEarnings);
|
|
||||||
lines.Add(debit > credit2 ? new GlLine(retained, 0, debit - credit2) : new GlLine(retained, credit2 - debit, 0));
|
|
||||||
}
|
|
||||||
return new CloseBuild(lines, income, expenses);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<YearEndPreview> GetYearEndPreviewAsync(int year, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var start = $"{year}-01-01";
|
|
||||||
var end = $"{year}-12-31";
|
|
||||||
|
|
||||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
|
|
||||||
return new(year, start, end, 0, 0, 0, false, "Run the General Ledger setup first.", false);
|
|
||||||
|
|
||||||
var sourceId = YearEndSourceId(year);
|
|
||||||
var existing = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct);
|
|
||||||
if (existing?.Status == GlPostingStatus.Posted)
|
|
||||||
return new(year, start, end, 0, 0, 0, false, $"{year} has already been closed.", true);
|
|
||||||
|
|
||||||
var build = await BuildYearEndCloseAsync(year, ct);
|
|
||||||
var net = build.Income - build.Expenses;
|
|
||||||
string? reason = null;
|
|
||||||
if (new DateOnly(year, 12, 31) > DateOnly.FromDateTime(DateTime.Now))
|
|
||||||
reason = $"{year} has not ended yet. A year can only be closed after 31 December.";
|
|
||||||
else if (await context.GlPostingLogs.AnyAsync(l => l.Status != GlPostingStatus.Posted && !(l.Event == YearEndEvent && l.SourceId == sourceId), ct))
|
|
||||||
reason = "There are failed or pending General Ledger postings. Retry them first so the year's figures are complete.";
|
|
||||||
else if (build.Lines.Count == 0)
|
|
||||||
reason = $"There is no income or expense activity in {year} to close.";
|
|
||||||
|
|
||||||
return new(year, start, end, build.Income, build.Expenses, net, reason is null, reason, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Posts the closing journal for the year (income/expense into Retained Earnings), then locks the year's periods.</summary>
|
|
||||||
public async Task<object> CloseYearAsync(int year, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var preview = await GetYearEndPreviewAsync(year, ct);
|
|
||||||
if (!preview.Closable) throw new InvalidOperationException(preview.Reason ?? "This year cannot be closed.");
|
|
||||||
|
|
||||||
var build = await BuildYearEndCloseAsync(year, ct);
|
|
||||||
var sourceId = YearEndSourceId(year);
|
|
||||||
await PostAsync(YearEndEvent, sourceId, new DateTime(year, 12, 31), "YEAR_END_CLOSE", $"Year-end closing entry for {year}",
|
|
||||||
() => Task.FromResult<List<GlLine>?>(build.Lines), ct, journalType: "CLOSING", reference: $"{YearEndEvent}:{year}");
|
|
||||||
|
|
||||||
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct)
|
|
||||||
?? throw new InvalidOperationException("The closing entry could not be prepared. Check the server log.");
|
|
||||||
if (row.Status != GlPostingStatus.Posted)
|
|
||||||
throw new InvalidOperationException((row.Error ?? "The General Ledger did not accept the closing entry.") + " Fix the cause and press Close again.");
|
|
||||||
|
|
||||||
var locked = await LockYearPeriodsAsync(year, ct);
|
|
||||||
return new { year, glJournalNo = row.GlJournalNo, netProfit = preview.NetProfit, periodsLocked = locked };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<int> LockYearPeriodsAsync(int year, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var ids = new HashSet<long>();
|
|
||||||
for (var month = 1; month <= 12; month++)
|
|
||||||
{
|
|
||||||
var p = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={year}-{month:00}-15", null, ct);
|
|
||||||
if (p.Success && GlJson.Data(p) is { } pd && GlJson.GetLong(pd, "periodId") is { } id) ids.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var locked = 0;
|
|
||||||
foreach (var id in ids)
|
|
||||||
{
|
|
||||||
var r = await gl.SendAsync(HttpMethod.Put, $"v1/periods/{id}/lock", new { action = "Close", reason = $"Year-end close {year}" }, ct);
|
|
||||||
if (r.Success) locked++;
|
|
||||||
else logger.LogWarning("Could not lock GL period {PeriodId} after closing {Year}: {Message}", id, year, GlJson.Message(r));
|
|
||||||
}
|
|
||||||
return locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Retry ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Re-sends every Failed/Pending posting. Returns how many are now Posted.</summary>
|
|
||||||
public async Task<int> RetryFailedAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var rows = await context.GlPostingLogs
|
|
||||||
.Where(l => l.Status != GlPostingStatus.Posted)
|
|
||||||
.OrderBy(l => l.CreatedAt)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var posted = 0;
|
|
||||||
foreach (var row in rows)
|
|
||||||
{
|
|
||||||
await SendAsync(row, ct);
|
|
||||||
if (row.Status == GlPostingStatus.Posted) posted++;
|
|
||||||
}
|
|
||||||
return posted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Core ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task PostAsync(string evt, Guid sourceId, DateTime date, string sourceModule, string narration,
|
|
||||||
Func<Task<List<GlLine>?>> buildLines, CancellationToken ct, string journalType = "GENERAL", string? reference = null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true") return; // GL not set up yet
|
|
||||||
|
|
||||||
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == evt && l.SourceId == sourceId, ct);
|
|
||||||
if (row is not null && row.Status == GlPostingStatus.Posted) return;
|
|
||||||
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
var lines = await buildLines();
|
|
||||||
if (lines is null || lines.Count == 0) return;
|
|
||||||
|
|
||||||
reference ??= $"{evt}:{sourceId}";
|
|
||||||
var payload = new Payload(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), journalType, sourceModule, reference,
|
|
||||||
narration, "Distribution", lines.Select(l => new PayloadLine(l.AccountCode, l.Debit, l.Credit)).ToList());
|
|
||||||
|
|
||||||
row = new GlPostingLog
|
|
||||||
{
|
|
||||||
Event = evt,
|
|
||||||
SourceId = sourceId,
|
|
||||||
Reference = reference,
|
|
||||||
PayloadJson = JsonSerializer.Serialize(payload, JsonOptions)
|
|
||||||
};
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try { await context.GlPostingLogs.AddAsync(row, ct); await uow.CommitAsync(ct); }
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
await SendAsync(row, ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "GL posting {Event}:{SourceId} failed before it could be sent", evt, sourceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendAsync(GlPostingLog row, CancellationToken ct)
|
|
||||||
{
|
|
||||||
string? journalNo = null;
|
|
||||||
string? error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var payload = JsonSerializer.Deserialize<JsonElement>(row.PayloadJson);
|
|
||||||
var entryDate = payload.GetProperty("entryDate").GetString()!;
|
|
||||||
|
|
||||||
var period = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={entryDate}", null, ct);
|
|
||||||
var periodId = period.Success && GlJson.Data(period) is { } pd ? GlJson.GetLong(pd, "periodId") : null;
|
|
||||||
if (periodId is null)
|
|
||||||
{
|
|
||||||
error = $"No accounting period in GL covers {entryDate}. Run GL setup for that year.";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var body = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(row.PayloadJson, JsonOptions)!;
|
|
||||||
body["periodId"] = JsonSerializer.SerializeToElement(periodId.Value);
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Post, "v1/journal-entries", body, ct);
|
|
||||||
if (result.Success)
|
|
||||||
journalNo = GlJson.Data(result) is { } d ? GlJson.GetString(d, "journalNo") ?? "" : "";
|
|
||||||
else
|
|
||||||
error = GlJson.Message(result) ?? $"GL returned HTTP {result.StatusCode}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
error = ex.Message;
|
|
||||||
logger.LogWarning(ex, "GL posting {Reference} failed", row.Reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.Attempts++;
|
|
||||||
row.LastAttemptAt = DateTime.Now;
|
|
||||||
row.Status = error is null ? GlPostingStatus.Posted : GlPostingStatus.Failed;
|
|
||||||
row.GlJournalNo = journalNo;
|
|
||||||
row.Error = error is null ? null : error.Length > 1000 ? error[..1000] : error;
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try { context.GlPostingLogs.Update(row); await uow.CommitAsync(ct); }
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Account lookups ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task<string> Acct(string key) =>
|
|
||||||
(await configRepo.GetByKeyAsync(key))?.Value is { Length: > 0 } code
|
|
||||||
? code
|
|
||||||
: throw new InvalidOperationException($"GL account mapping '{key}' is missing. Run GL setup.");
|
|
||||||
|
|
||||||
private async Task<string> BankCode(Guid? bankAccountId)
|
|
||||||
{
|
|
||||||
if (bankAccountId is null) throw new InvalidOperationException("No cash/bank account on this transaction.");
|
|
||||||
var account = await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == bankAccountId.Value)
|
|
||||||
?? throw new InvalidOperationException("Cash/bank account not found.");
|
|
||||||
return await setup.EnsureBankAccountAsync(account)
|
|
||||||
?? throw new InvalidOperationException("Could not map the cash/bank account to a GL account. Run GL setup.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Distribution.Data;
|
|
||||||
using Distribution.Features.SysConfig;
|
|
||||||
using Distribution.Infra.UoW;
|
|
||||||
using Distribution.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Distribution.Features.GeneralLedger;
|
|
||||||
|
|
||||||
public record GlSetupResult(bool Done, List<string> Actions);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One-time (re-runnable) bootstrap of the General Ledger service for Distribution: creates the chart of accounts
|
|
||||||
/// Distribution posts to, a fiscal year with its periods, the GL cash-root config, and a GL account for every
|
|
||||||
/// Distribution cash/bank account. The resulting GL account codes are stored in SysConfig (see GlKeys).
|
|
||||||
/// </summary>
|
|
||||||
public class GlSetupService(
|
|
||||||
IGeneralLedgerClient gl,
|
|
||||||
ISysConfigRepository configRepo,
|
|
||||||
AppDBContext context,
|
|
||||||
IUnitOfWork uow)
|
|
||||||
{
|
|
||||||
private const short Asset = 1, Liability = 2, Equity = 3, Income = 4;
|
|
||||||
|
|
||||||
// key, GL account name, account type, parent key, postable, tag name, tag value.
|
|
||||||
// Operating = working-capital account for the Cash Flow report (cashFlowCategory OPERATING).
|
|
||||||
private record AccountDef(string Key, string Name, short TypeId, string? ParentKey, bool Postable, string? TagField, string? TagValue, bool Operating = false);
|
|
||||||
|
|
||||||
private static readonly AccountDef[] Chart =
|
|
||||||
[
|
|
||||||
new(GlKeys.CashEquivalents, "Cash and Cash Equivalents", Asset, null, false, "balanceSheetClassification", "CURRENT_ASSET"),
|
|
||||||
new(GlKeys.CashInHand, "Cash in Hand", Asset, GlKeys.CashEquivalents, false, null, null),
|
|
||||||
new(GlKeys.BankAccounts, "Bank Accounts", Asset, GlKeys.CashEquivalents, false, null, null),
|
|
||||||
new(GlKeys.ChequesInHand, "Cheques in Hand", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.Receivable, "Trade Receivables", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.Inventory, "Inventory", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.InputVat, "Input VAT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.InputNbt, "Input NBT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
|
||||||
new(GlKeys.Payable, "Trade Payables", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
|
||||||
new(GlKeys.ChequesIssued, "Cheques Issued - Uncleared", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
|
||||||
new(GlKeys.Equity, "Owner's Equity", Equity, null, false, null, null),
|
|
||||||
new(GlKeys.ShareCapital, "Share Capital / Owner's Capital", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.RetainedEarnings, "Retained Earnings", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.Drawings, "Drawings and Dividends", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.GeneralReserve, "General Reserve", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.RevaluationReserve, "Revaluation Reserve", Equity, GlKeys.Equity, true, null, null),
|
|
||||||
new(GlKeys.Sales, "Sales Revenue", Income, null, true, "incomeStatementSection", "SALES"),
|
|
||||||
];
|
|
||||||
|
|
||||||
public async Task<GlSetupResult> RunAsync(int? year, Guid? userId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (!await gl.IsHealthyAsync(ct))
|
|
||||||
throw new InvalidOperationException("The General Ledger service is not reachable. Check GeneralLedger:BaseUrl and that the service is running.");
|
|
||||||
|
|
||||||
var actions = new List<string>();
|
|
||||||
var codes = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
foreach (var key in GlKeys.AllAccountKeys)
|
|
||||||
{
|
|
||||||
var existing = (await configRepo.GetByKeyAsync(key))?.Value;
|
|
||||||
if (!string.IsNullOrWhiteSpace(existing)) codes[key] = existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Chart of accounts (skips accounts whose code is already stored and still exists in GL).
|
|
||||||
foreach (var def in Chart)
|
|
||||||
{
|
|
||||||
if (codes.TryGetValue(def.Key, out var code) && await AccountIdAsync(code, ct) is not null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
long? parentId = null;
|
|
||||||
if (def.ParentKey is not null)
|
|
||||||
parentId = await AccountIdAsync(codes[def.ParentKey], ct);
|
|
||||||
|
|
||||||
var created = await CreateAccountAsync(def.Name, def.TypeId, parentId, def.Postable, ct);
|
|
||||||
codes[def.Key] = created.Code;
|
|
||||||
actions.Add($"Created GL account {created.Code} - {def.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags are applied on every run (idempotent), so accounts created by an earlier run are corrected too.
|
|
||||||
foreach (var def in Chart)
|
|
||||||
{
|
|
||||||
var tags = new Dictionary<string, object?>();
|
|
||||||
if (def.TagField is not null) tags[def.TagField] = def.TagValue;
|
|
||||||
if (def.Operating) tags["cashFlowCategory"] = "OPERATING";
|
|
||||||
if (!def.Postable) tags["isPostable"] = false; // header accounts (re-applied so older runs are corrected)
|
|
||||||
if (tags.Count > 0) await UpdateAccountAsync(codes[def.Key], tags, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fiscal year + periods for the requested year (default: current year).
|
|
||||||
var fy = year ?? DateTime.Now.Year;
|
|
||||||
var probe = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={fy}-06-30", null, ct);
|
|
||||||
if (!probe.Success)
|
|
||||||
{
|
|
||||||
var fyResult = await gl.SendAsync(HttpMethod.Post, "v1/fiscal-years", new
|
|
||||||
{
|
|
||||||
yearName = $"FY{fy}",
|
|
||||||
startDate = $"{fy}-01-01",
|
|
||||||
endDate = $"{fy}-12-31",
|
|
||||||
autoGeneratePeriods = true,
|
|
||||||
periodCount = 12
|
|
||||||
}, ct);
|
|
||||||
if (!fyResult.Success)
|
|
||||||
throw new InvalidOperationException($"Could not create fiscal year {fy} in GL: {GlJson.Message(fyResult) ?? fyResult.StatusCode.ToString()}");
|
|
||||||
actions.Add($"Created fiscal year FY{fy} with 12 periods");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. GL cash root (GL locks this key after one edit, so only set it when empty).
|
|
||||||
var cashRootId = await AccountIdAsync(codes[GlKeys.CashEquivalents], ct);
|
|
||||||
var cfgResult = await gl.SendAsync(HttpMethod.Get, "v1/system-config", null, ct);
|
|
||||||
var currentRoot = ReadGlConfigValue(cfgResult, "CASH_ACCOUNT_ROOT_ID");
|
|
||||||
if (string.IsNullOrWhiteSpace(currentRoot) && cashRootId is not null)
|
|
||||||
{
|
|
||||||
var set = await gl.SendAsync(HttpMethod.Put, "v1/system-config/CASH_ACCOUNT_ROOT_ID",
|
|
||||||
new { configValue = cashRootId.Value.ToString() }, ct);
|
|
||||||
if (!set.Success)
|
|
||||||
throw new InvalidOperationException($"Could not set CASH_ACCOUNT_ROOT_ID in GL: {GlJson.Message(set) ?? set.StatusCode.ToString()}");
|
|
||||||
actions.Add("Set GL CASH_ACCOUNT_ROOT_ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Persist the mapping, then mirror every existing Distribution cash/bank account.
|
|
||||||
await SaveConfigAsync(codes, userId, ct);
|
|
||||||
|
|
||||||
foreach (var account in await context.BankAccounts.Where(a => a.GlAccountCode == null).ToListAsync(ct))
|
|
||||||
{
|
|
||||||
var code = await EnsureBankAccountAsync(account, ct);
|
|
||||||
if (code is not null) actions.Add($"Mirrored {account.AccountName} as GL account {code}");
|
|
||||||
}
|
|
||||||
|
|
||||||
await SaveConfigAsync(new Dictionary<string, string> { [GlKeys.SetupDone] = "true" }, userId, ct);
|
|
||||||
return new GlSetupResult(true, actions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the GL account code for a Distribution cash/bank account, creating the GL child account on first use.
|
|
||||||
/// Saves the code on the BankAccount. Returns null if GL setup has not been run.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string?> EnsureBankAccountAsync(Models.BankAccount account, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(account.GlAccountCode)) return account.GlAccountCode;
|
|
||||||
|
|
||||||
var parentKey = account.AccountType == BankAccountType.Bank ? GlKeys.BankAccounts : GlKeys.CashInHand;
|
|
||||||
var parentCode = (await configRepo.GetByKeyAsync(parentKey))?.Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(parentCode)) return null;
|
|
||||||
|
|
||||||
var parentId = await AccountIdAsync(parentCode, ct);
|
|
||||||
if (parentId is null) return null;
|
|
||||||
|
|
||||||
var name = account.AccountType == BankAccountType.Bank && !string.IsNullOrWhiteSpace(account.BankName)
|
|
||||||
? $"{account.BankName} - {account.AccountName}"
|
|
||||||
: account.AccountName;
|
|
||||||
|
|
||||||
var created = await CreateAccountAsync(name, Asset, parentId, true, ct);
|
|
||||||
|
|
||||||
account.GlAccountCode = created.Code;
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
context.BankAccounts.Update(account);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
return created.Code;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveConfigAsync(Dictionary<string, string> values, Guid? userId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var (key, value) in values)
|
|
||||||
await configRepo.UpsertAsync(new Models.SysConfig { Key = key, Value = value, UpdatedAt = DateTime.Now, UpdatedBy = userId });
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<long?> AccountIdAsync(string code, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Get, $"v1/accounts/{Uri.EscapeDataString(code)}", null, ct);
|
|
||||||
if (!result.Success) return null;
|
|
||||||
var data = GlJson.Data(result);
|
|
||||||
if (data is null) return null;
|
|
||||||
// GET /accounts/{code} wraps the account: { account: {...}, ... }
|
|
||||||
var account = GlJson.Get(data.Value, "account") ?? data.Value;
|
|
||||||
return GlJson.GetLong(account, "accountId");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(long Id, string Code)> CreateAccountAsync(string name, short typeId, long? parentId, bool postable, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Post, "v1/accounts", new
|
|
||||||
{
|
|
||||||
accountName = name,
|
|
||||||
parentAccountId = parentId,
|
|
||||||
accountTypeId = typeId,
|
|
||||||
isControlAccount = false,
|
|
||||||
isPostable = postable
|
|
||||||
}, ct);
|
|
||||||
if (!result.Success)
|
|
||||||
throw new InvalidOperationException($"Could not create GL account '{name}': {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
|
||||||
|
|
||||||
var data = GlJson.Data(result) ?? throw new InvalidOperationException($"GL returned no data when creating account '{name}'.");
|
|
||||||
var id = GlJson.GetLong(data, "accountId");
|
|
||||||
var code = GlJson.GetString(data, "accountCode");
|
|
||||||
if (id is null || code is null)
|
|
||||||
throw new InvalidOperationException($"GL returned an unexpected body when creating account '{name}'.");
|
|
||||||
return (id.Value, code);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateAccountAsync(string code, Dictionary<string, object?> fields, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await gl.SendAsync(HttpMethod.Put, $"v1/accounts/{Uri.EscapeDataString(code)}", fields, ct);
|
|
||||||
if (!result.Success)
|
|
||||||
throw new InvalidOperationException($"Could not update GL account {code}: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ReadGlConfigValue(GeneralLedgerResult result, string key)
|
|
||||||
{
|
|
||||||
if (!result.Success || GlJson.Data(result) is not { } data) return null;
|
|
||||||
var items = data.ValueKind == JsonValueKind.Array ? data : GlJson.Get(data, "items");
|
|
||||||
if (items is not { ValueKind: JsonValueKind.Array }) return null;
|
|
||||||
foreach (var item in items.Value.EnumerateArray())
|
|
||||||
if (GlJson.GetString(item, "configKey") == key) return GlJson.GetString(item, "configValue");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.SysConfig;
|
using Distribution.Features.SysConfig;
|
||||||
@@ -14,8 +13,7 @@ public class GrnService(
|
|||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
Distribution.Features.Item.IItemRepository itemRepo,
|
Distribution.Features.Item.IItemRepository itemRepo,
|
||||||
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
||||||
Distribution.Features.SysConfig.SysConfigService sysConfig,
|
Distribution.Features.SysConfig.SysConfigService sysConfig)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
||||||
{
|
{
|
||||||
@@ -484,7 +482,6 @@ public class GrnService(
|
|||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||||
await gl.PostGrnAsync(saved!, ct);
|
|
||||||
return Map(saved!);
|
return Map(saved!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ public interface IInvoiceRepository
|
|||||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
||||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||||
|
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||||
Task AddAsync(Models.Invoice invoice);
|
Task AddAsync(Models.Invoice invoice);
|
||||||
|
Task<Models.Item?> GetItemAsync(Guid itemId);
|
||||||
|
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
|
||||||
|
Task AddVersionAsync(InvoiceVersion version);
|
||||||
|
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
|
||||||
|
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
|
||||||
void Update(Models.Invoice invoice);
|
void Update(Models.Invoice invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,52 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/preview-totals")]
|
||||||
|
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
|
||||||
|
{
|
||||||
|
var result = await service.PreviewTotalsAsync(id, request);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/versions")]
|
||||||
|
public async Task<IActionResult> GetVersions(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetVersionsAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/items")]
|
||||||
|
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.EditedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/print")]
|
[HttpPost("{id:guid}/print")]
|
||||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,48 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Features.ShopPO;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Invoice;
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
|
||||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||||
|
|
||||||
|
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||||
|
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||||
|
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
|
||||||
|
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||||
|
|
||||||
|
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
|
||||||
|
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
|
||||||
|
// Quantity = how many free units the scheme still entitles for the lines as typed.
|
||||||
|
public record FreeEntitlement(Guid SchemeId, int Quantity);
|
||||||
|
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
|
||||||
|
|
||||||
|
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
|
||||||
|
|
||||||
|
public record InvoiceVersionResponse(
|
||||||
|
Guid Id,
|
||||||
|
int VersionNumber,
|
||||||
|
InvoiceVersionStage Stage,
|
||||||
|
decimal TotalAmount,
|
||||||
|
Guid CreatedBy,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceVersionItemResponse> Items);
|
||||||
|
|
||||||
|
public record InvoiceItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid? ShopPOItemId,
|
||||||
|
Guid ItemId,
|
||||||
|
string ItemName,
|
||||||
|
int? OrderedQuantity,
|
||||||
|
int Quantity,
|
||||||
|
decimal UnitPrice,
|
||||||
|
bool IsRemoved,
|
||||||
|
bool IsFree = false,
|
||||||
|
Guid? SchemeId = null,
|
||||||
|
string? SchemeName = null);
|
||||||
|
|
||||||
public record InvoiceResponse(
|
public record InvoiceResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ShopPOId,
|
Guid ShopPOId,
|
||||||
@@ -20,8 +56,11 @@ public record InvoiceResponse(
|
|||||||
DateTime? FinalizedAt,
|
DateTime? FinalizedAt,
|
||||||
Guid? FinalizedBy,
|
Guid? FinalizedBy,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
|
decimal DiscountAmount,
|
||||||
decimal PaidAmount,
|
decimal PaidAmount,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceItemResponse> Items,
|
||||||
|
List<ShopPODiscountResponse>? Discounts = null);
|
||||||
|
|
||||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
await context.Invoices
|
await context.Invoices
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||||
|
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
|
||||||
|
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
|
||||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||||
|
|
||||||
@@ -49,9 +51,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
|
||||||
|
await context.SchemeRedemptions
|
||||||
|
.Where(r => r.FK_ShopPOId == shopPOId)
|
||||||
|
.SumAsync(r => r.DiscountAmount) ?? 0m;
|
||||||
|
|
||||||
public async Task AddAsync(Models.Invoice invoice) =>
|
public async Task AddAsync(Models.Invoice invoice) =>
|
||||||
await context.Invoices.AddAsync(invoice);
|
await context.Invoices.AddAsync(invoice);
|
||||||
|
|
||||||
|
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
|
||||||
|
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
|
||||||
|
|
||||||
|
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
|
||||||
|
await context.InvoiceItems.AddRangeAsync(items);
|
||||||
|
|
||||||
|
public async Task AddVersionAsync(InvoiceVersion version) =>
|
||||||
|
await context.InvoiceVersions.AddAsync(version);
|
||||||
|
|
||||||
|
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
|
||||||
|
await context.InvoiceVersions
|
||||||
|
.Where(v => v.FK_InvoiceId == invoiceId)
|
||||||
|
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
|
||||||
|
|
||||||
|
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
|
||||||
|
await context.InvoiceVersions
|
||||||
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
|
||||||
|
.OrderBy(v => v.VersionNumber)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
public void Update(Models.Invoice invoice) =>
|
public void Update(Models.Invoice invoice) =>
|
||||||
context.Invoices.Update(invoice);
|
context.Invoices.Update(invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.GeneralLedger;
|
using Distribution.Features.Scheme;
|
||||||
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -8,9 +9,10 @@ namespace Distribution.Features.Invoice;
|
|||||||
|
|
||||||
public class InvoiceService(
|
public class InvoiceService(
|
||||||
IInvoiceRepository repo,
|
IInvoiceRepository repo,
|
||||||
|
IShopPORepository shopPoRepo,
|
||||||
|
SchemeService schemeService,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||||
new(i.Id, i.FK_ShopPOId,
|
new(i.Id, i.FK_ShopPOId,
|
||||||
@@ -19,14 +21,114 @@ public class InvoiceService(
|
|||||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||||
i.PrintedAt, i.PrintedBy,
|
i.PrintedAt, i.PrintedBy,
|
||||||
i.FinalizedAt, i.FinalizedBy,
|
i.FinalizedAt, i.FinalizedBy,
|
||||||
i.TotalAmount,
|
i.TotalAmount, i.DiscountAmount,
|
||||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
||||||
i.IsActive, i.CreatedAt);
|
i.IsActive, i.CreatedAt,
|
||||||
|
i.Items.Where(x => x.IsActive).Select(x =>
|
||||||
|
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
||||||
|
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
||||||
|
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
|
||||||
|
|
||||||
|
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||||
|
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||||
|
|
||||||
|
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
|
||||||
|
// scheme rules ignore them.
|
||||||
|
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
|
||||||
|
items.Where(i => i.IsActive && !i.IsRemoved)
|
||||||
|
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
|
||||||
|
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
|
||||||
|
// and at print/finalize. The redemption amount is updated with it.
|
||||||
|
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||||
|
{
|
||||||
|
var lines = items.ToList();
|
||||||
|
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
|
||||||
|
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
|
||||||
|
var billable = BillableTotal(lines);
|
||||||
|
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
|
||||||
|
invoice.DiscountAmount = Math.Min(discount, billable);
|
||||||
|
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
|
||||||
|
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
|
||||||
|
{
|
||||||
|
var invoice = await repo.GetByIdAsync(id);
|
||||||
|
if (invoice is null) return null;
|
||||||
|
|
||||||
|
var lines = request.Items.Where(l => !l.IsRemoved)
|
||||||
|
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
|
||||||
|
.ToList();
|
||||||
|
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
|
||||||
|
var chosen = request.DiscountSchemeIds is null
|
||||||
|
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
|
||||||
|
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
|
||||||
|
var discount = Math.Min(chosen, billable);
|
||||||
|
|
||||||
|
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
|
||||||
|
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
|
||||||
|
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
|
||||||
|
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable copy of the invoice lines as they stand right now.
|
||||||
|
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoice.Id,
|
||||||
|
VersionNumber = versionNumber,
|
||||||
|
Stage = stage,
|
||||||
|
TotalAmount = invoice.TotalAmount,
|
||||||
|
CreatedBy = userId,
|
||||||
|
Items = items.Select(i => new InvoiceVersionItem
|
||||||
|
{
|
||||||
|
FK_ShopPOItemId = i.FK_ShopPOItemId,
|
||||||
|
FK_ItemId = i.FK_ItemId,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitPrice = i.UnitPrice,
|
||||||
|
IsRemoved = i.IsRemoved
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
|
||||||
|
{
|
||||||
|
if (await repo.GetByIdAsync(id) is null) return null;
|
||||||
|
var versions = await repo.GetVersionsAsync(id);
|
||||||
|
return versions.Select(v => new InvoiceVersionResponse(
|
||||||
|
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
|
||||||
|
v.Items.Select(i => new InvoiceVersionItemResponse(
|
||||||
|
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
|
||||||
|
{
|
||||||
|
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
|
||||||
|
// Added lines may or may not be in invoice.Items yet, so merge by reference.
|
||||||
|
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
|
||||||
|
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
|
||||||
|
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoiceId,
|
||||||
|
FK_ShopPOItemId = i.Id,
|
||||||
|
FK_ItemId = i.FK_ItemId,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitPrice = i.UnitPrice,
|
||||||
|
IsFree = i.IsFree,
|
||||||
|
FK_SchemeId = i.FK_SchemeId,
|
||||||
|
SchemeName = i.SchemeName
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(id);
|
var invoice = await repo.GetByIdAsync(id);
|
||||||
return invoice is null ? null : ToResponse(invoice);
|
if (invoice is null) return null;
|
||||||
|
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
||||||
@@ -44,6 +146,131 @@ public class InvoiceService(
|
|||||||
return new OutstandingPaymentsResponse(count, outstanding);
|
return new OutstandingPaymentsResponse(count, outstanding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
||||||
|
?? throw new KeyNotFoundException("Shop PO not found");
|
||||||
|
|
||||||
|
if (po.ConfirmedAt is null)
|
||||||
|
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
||||||
|
if (po.Status == ShopPOStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
||||||
|
if (po.InvoiceId is not null)
|
||||||
|
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
||||||
|
|
||||||
|
var invoice = new Models.Invoice
|
||||||
|
{
|
||||||
|
FK_ShopPOId = po.Id,
|
||||||
|
Status = InvoiceStatus.Draft,
|
||||||
|
PaymentStatus = PaymentStatus.Pending,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||||
|
await ApplyTotalsAsync(invoice, lines);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(invoice);
|
||||||
|
await repo.AddItemsAsync(lines);
|
||||||
|
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
||||||
|
po.InvoiceId = invoice.Id;
|
||||||
|
shopPoRepo.Update(po);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(invoice.Id);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Invoice not found");
|
||||||
|
|
||||||
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
|
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
|
||||||
|
|
||||||
|
var newLines = new List<InvoiceItem>();
|
||||||
|
|
||||||
|
foreach (var edit in request.Items)
|
||||||
|
{
|
||||||
|
if (edit.UnitPrice is < 0)
|
||||||
|
throw new InvalidOperationException("Unit price cannot be negative");
|
||||||
|
|
||||||
|
if (edit.InvoiceItemId is Guid lineId)
|
||||||
|
{
|
||||||
|
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
|
||||||
|
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
|
||||||
|
|
||||||
|
item.IsRemoved = edit.IsRemoved;
|
||||||
|
if (!edit.IsRemoved)
|
||||||
|
{
|
||||||
|
ValidateQuantity(item, edit.Quantity);
|
||||||
|
item.Quantity = edit.Quantity;
|
||||||
|
}
|
||||||
|
if (edit.UnitPrice is decimal price)
|
||||||
|
{
|
||||||
|
if (item.IsFree && price != 0m)
|
||||||
|
throw new InvalidOperationException("A free-issue line must stay at zero price");
|
||||||
|
item.UnitPrice = price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (edit.ItemId is Guid itemId)
|
||||||
|
{
|
||||||
|
// New line for an item that was not on the PO.
|
||||||
|
if (edit.IsRemoved) continue;
|
||||||
|
if (edit.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
|
||||||
|
if (edit.UnitPrice is null)
|
||||||
|
throw new InvalidOperationException("Unit price is required for an added item");
|
||||||
|
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
|
||||||
|
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
|
||||||
|
|
||||||
|
var catalogItem = await repo.GetItemAsync(itemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {itemId} not found");
|
||||||
|
|
||||||
|
newLines.Add(new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoice.Id,
|
||||||
|
FK_ItemId = catalogItem.Id,
|
||||||
|
Item = catalogItem,
|
||||||
|
Quantity = edit.Quantity,
|
||||||
|
UnitPrice = edit.UnitPrice.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var allLines = invoice.Items.Concat(newLines).ToList();
|
||||||
|
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
||||||
|
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
||||||
|
await ApplyTotalsAsync(invoice, allLines);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return (await GetByIdAsync(id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||||
|
{
|
||||||
|
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
|
||||||
|
if (quantity < 0 || quantity > ordered)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Quantity for item {item.Id} must be between 0 and {ordered}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(id)
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
@@ -52,12 +279,21 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Draft)
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
||||||
|
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
|
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Printed;
|
invoice.Status = InvoiceStatus.Printed;
|
||||||
invoice.PrintedAt = DateTime.Now;
|
invoice.PrintedAt = DateTime.Now;
|
||||||
invoice.PrintedBy = printedBy;
|
invoice.PrintedBy = printedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
@@ -71,35 +307,39 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Printed)
|
if (invoice.Status != InvoiceStatus.Printed)
|
||||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
var adjustments = request.Adjustments ?? [];
|
var adjustments = request.Adjustments ?? [];
|
||||||
if (adjustments.Count > 0)
|
|
||||||
{
|
|
||||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
|
||||||
decimal total = 0;
|
|
||||||
foreach (var item in poItems)
|
|
||||||
{
|
|
||||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
|
||||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
|
||||||
|
|
||||||
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
||||||
throw new InvalidOperationException(
|
foreach (var adjustment in adjustments)
|
||||||
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
{
|
||||||
|
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||||
|
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||||
|
|
||||||
total += billedQuantity * item.UnitPrice;
|
item.IsRemoved = adjustment.IsRemoved;
|
||||||
|
if (!adjustment.IsRemoved)
|
||||||
|
{
|
||||||
|
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||||
|
item.Quantity = adjustment.AdjustedQuantity;
|
||||||
}
|
}
|
||||||
invoice.TotalAmount = total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
await ApplyTotalsAsync(invoice, items);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Finalized;
|
invoice.Status = InvoiceStatus.Finalized;
|
||||||
invoice.FinalizedAt = DateTime.Now;
|
invoice.FinalizedAt = DateTime.Now;
|
||||||
invoice.FinalizedBy = finalizedBy;
|
invoice.FinalizedBy = finalizedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
try
|
||||||
|
{
|
||||||
|
repo.Update(invoice);
|
||||||
|
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
await gl.PostInvoiceFinalizedAsync(invoice, ct);
|
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public interface INotificationRepository
|
||||||
|
{
|
||||||
|
// ── Inbox ────────────────────────────────────────────────────────────
|
||||||
|
Task AddAsync(Models.Notification notification);
|
||||||
|
/// <summary>Stops tracking a notification (and its recipients) whose save failed.</summary>
|
||||||
|
void Discard(Models.Notification notification);
|
||||||
|
|
||||||
|
Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly);
|
||||||
|
Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize);
|
||||||
|
Task<int> GetUnreadCountAsync(Guid userId);
|
||||||
|
Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId);
|
||||||
|
Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId);
|
||||||
|
void RemoveRecipient(Models.NotificationRecipient recipient);
|
||||||
|
|
||||||
|
// ── Recipient resolution ─────────────────────────────────────────────
|
||||||
|
Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds);
|
||||||
|
Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode);
|
||||||
|
Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id);
|
||||||
|
Task<Guid?> GetReportsToUserIdAsync(Guid userId);
|
||||||
|
Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id);
|
||||||
|
|
||||||
|
// ── Retention ────────────────────────────────────────────────────────
|
||||||
|
Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take);
|
||||||
|
Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take);
|
||||||
|
void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients);
|
||||||
|
void RemoveNotifications(IEnumerable<Models.Notification> notifications);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Daily retention purge: deletes read inbox rows older than <c>Notifications:RetentionDays</c>
|
||||||
|
/// (default 90), then any notification left with no recipients.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationCleanupJob(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<NotificationCleanupJob> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private const int BatchSize = 500;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Let the app finish starting before the first run.
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await RunOnceAsync(stoppingToken); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) { logger.LogError(ex, "Notification cleanup failed."); }
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunOnceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var retentionDays = config.GetValue("Notifications:RetentionDays", 90);
|
||||||
|
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||||
|
|
||||||
|
using var scope = scopes.CreateScope();
|
||||||
|
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||||
|
var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||||
|
|
||||||
|
var removed = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var expired = await repo.GetExpiredReadRecipientsAsync(cutoff, BatchSize);
|
||||||
|
if (expired.Count == 0) break;
|
||||||
|
|
||||||
|
repo.RemoveRecipients(expired);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
removed += expired.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var orphans = await repo.GetOrphanNotificationsAsync(BatchSize);
|
||||||
|
if (orphans.Count == 0) break;
|
||||||
|
|
||||||
|
repo.RemoveNotifications(orphans);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0)
|
||||||
|
logger.LogInformation("Notification cleanup removed {Count} read inbox rows older than {Days} days.", removed, retentionDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>Every endpoint acts on the signed-in user's own notifications only.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/notifications")]
|
||||||
|
public class NotificationController(NotificationService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetInbox(
|
||||||
|
[FromQuery] bool unreadOnly = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
return Ok(await service.GetInboxAsync(userId, unreadOnly, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("unread-count")]
|
||||||
|
public async Task<IActionResult> GetUnreadCount()
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
return Ok(await service.GetUnreadCountAsync(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/read")]
|
||||||
|
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.MarkReadAsync(userId, id, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("read-all")]
|
||||||
|
public async Task<IActionResult> MarkAllRead(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
await service.MarkAllReadAsync(userId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.DeleteAsync(userId, id, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <param name="Values">Object whose public properties fill the {Token} placeholders in the type's templates.</param>
|
||||||
|
/// <param name="CreatedByUserId">The acting user; excluded from the recipients.</param>
|
||||||
|
public record NotificationArgs(
|
||||||
|
object? Values,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
string Link,
|
||||||
|
Guid? CreatedByUserId = null);
|
||||||
|
|
||||||
|
/// <param name="Id">The caller's own recipient row id — use it for read / delete.</param>
|
||||||
|
public record NotificationResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Type,
|
||||||
|
NotificationSeverity Severity,
|
||||||
|
string Title,
|
||||||
|
string Message,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
string Link,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool IsRead,
|
||||||
|
DateTime? ReadAt);
|
||||||
|
|
||||||
|
public record UnreadCountResponse(int Count);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server → client only. The server pushes a "notification" event to each recipient; clients never
|
||||||
|
/// invoke hub methods. Users are addressed by their id (see <see cref="NotificationUserIdProvider"/>).
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public class NotificationHub : Hub
|
||||||
|
{
|
||||||
|
public const string Path = "/hubs/notifications";
|
||||||
|
public const string ClientEvent = "notification";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a SignalR connection to the "UserId" claim that <c>AuthMiddleware</c> puts on the principal.</summary>
|
||||||
|
public class NotificationUserIdProvider : IUserIdProvider
|
||||||
|
{
|
||||||
|
public string? GetUserId(HubConnectionContext connection) =>
|
||||||
|
connection.User?.FindFirst("UserId")?.Value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public class NotificationRecipientResolver(INotificationRepository repo)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Expands the rules into a de-duplicated list of active user ids, never including
|
||||||
|
/// <paramref name="actingUserId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<Guid>> ResolveAsync(IEnumerable<RecipientRule> rules, Guid? actingUserId)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<Guid>();
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
ids.UnionWith(await ResolveRuleAsync(rule));
|
||||||
|
|
||||||
|
if (actingUserId is { } actor)
|
||||||
|
ids.Remove(actor);
|
||||||
|
|
||||||
|
if (ids.Count == 0) return [];
|
||||||
|
return await repo.FilterActiveUserIdsAsync(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<Guid>> ResolveRuleAsync(RecipientRule rule)
|
||||||
|
{
|
||||||
|
switch (rule)
|
||||||
|
{
|
||||||
|
case UsersRule r:
|
||||||
|
return r.Ids;
|
||||||
|
|
||||||
|
case PermissionRule r:
|
||||||
|
return await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||||
|
|
||||||
|
case ScopeRule r:
|
||||||
|
return await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||||
|
|
||||||
|
case PermissionInScopeRule r:
|
||||||
|
var withPermission = await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||||
|
var inScope = await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||||
|
return withPermission.Intersect(inScope);
|
||||||
|
|
||||||
|
case ReportsToRule r:
|
||||||
|
var supervisor = await repo.GetReportsToUserIdAsync(r.UserId);
|
||||||
|
return supervisor is { } s ? [s] : [];
|
||||||
|
|
||||||
|
case RouteOwnerRule r:
|
||||||
|
var owner = await repo.GetRouteOwnerUserIdAsync(r.Kind, r.Id);
|
||||||
|
return owner is { } o ? [o] : [];
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unknown recipient rule {rule.GetType().Name}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public class NotificationRepository(AppDBContext context) : INotificationRepository
|
||||||
|
{
|
||||||
|
// ── Inbox ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Notification notification) =>
|
||||||
|
await context.Notifications.AddAsync(notification);
|
||||||
|
|
||||||
|
public void Discard(Models.Notification notification)
|
||||||
|
{
|
||||||
|
foreach (var recipient in notification.Recipients)
|
||||||
|
context.Entry(recipient).State = EntityState.Detached;
|
||||||
|
context.Entry(notification).State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Models.NotificationRecipient> InboxQuery(Guid userId, bool unreadOnly)
|
||||||
|
{
|
||||||
|
var query = context.NotificationRecipients.Where(r => r.FK_UserId == userId && r.IsActive);
|
||||||
|
return unreadOnly ? query.Where(r => !r.IsRead) : query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly) =>
|
||||||
|
InboxQuery(userId, unreadOnly).CountAsync();
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize) =>
|
||||||
|
InboxQuery(userId, unreadOnly)
|
||||||
|
.Include(r => r.Notification)
|
||||||
|
.OrderByDescending(r => r.Notification.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<int> GetUnreadCountAsync(Guid userId) =>
|
||||||
|
InboxQuery(userId, unreadOnly: true).CountAsync();
|
||||||
|
|
||||||
|
public Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId) =>
|
||||||
|
context.NotificationRecipients
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == recipientId && r.FK_UserId == userId && r.IsActive);
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId) =>
|
||||||
|
InboxQuery(userId, unreadOnly: true).ToListAsync();
|
||||||
|
|
||||||
|
public void RemoveRecipient(Models.NotificationRecipient recipient) =>
|
||||||
|
context.NotificationRecipients.Remove(recipient);
|
||||||
|
|
||||||
|
// ── Recipient resolution ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds)
|
||||||
|
{
|
||||||
|
var ids = userIds.ToList();
|
||||||
|
return context.Users
|
||||||
|
.Where(u => u.IsActive && ids.Contains(u.Id))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode) =>
|
||||||
|
context.Users
|
||||||
|
.Where(u => u.IsActive && context.RolePermissions.Any(rp =>
|
||||||
|
rp.FK_RoleId == u.FK_RoleId && rp.IsActive &&
|
||||||
|
rp.Permission.IsActive && rp.Permission.Code == permissionCode))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id)
|
||||||
|
{
|
||||||
|
Guid? regionId = null;
|
||||||
|
Guid? routeId = null;
|
||||||
|
Guid? storeId = null;
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ScopeKind.Region:
|
||||||
|
regionId = id;
|
||||||
|
break;
|
||||||
|
case ScopeKind.Route:
|
||||||
|
routeId = id;
|
||||||
|
regionId = await context.Routes.Where(r => r.Id == id)
|
||||||
|
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||||
|
break;
|
||||||
|
case ScopeKind.Store:
|
||||||
|
storeId = id;
|
||||||
|
routeId = await context.Stores.Where(s => s.Id == id)
|
||||||
|
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync();
|
||||||
|
if (routeId is { } rid)
|
||||||
|
regionId = await context.Routes.Where(r => r.Id == rid)
|
||||||
|
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIds = new HashSet<Guid>();
|
||||||
|
|
||||||
|
if (regionId is { } region)
|
||||||
|
userIds.UnionWith(await context.UserRegionScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_RegionId == region)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
if (routeId is { } route)
|
||||||
|
userIds.UnionWith(await context.UserRouteScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_RouteId == route)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
if (storeId is { } store)
|
||||||
|
userIds.UnionWith(await context.UserStoreScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_StoreId == store)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
return userIds.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Guid?> GetReportsToUserIdAsync(Guid userId) =>
|
||||||
|
context.Users.Where(u => u.Id == userId)
|
||||||
|
.Select(u => u.FK_ReportsToUserId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id)
|
||||||
|
{
|
||||||
|
Guid? routeId = kind switch
|
||||||
|
{
|
||||||
|
ScopeKind.Route => id,
|
||||||
|
ScopeKind.Store => await context.Stores.Where(s => s.Id == id)
|
||||||
|
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (routeId is not { } rid) return null;
|
||||||
|
|
||||||
|
return await context.Routes.Where(r => r.Id == rid && r.IsActive)
|
||||||
|
.Select(r => r.FK_OwnerUserId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Retention ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take) =>
|
||||||
|
context.NotificationRecipients
|
||||||
|
.Where(r => r.IsRead && r.ReadAt != null && r.ReadAt < cutoff)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take) =>
|
||||||
|
context.Notifications
|
||||||
|
.Where(n => !n.Recipients.Any())
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients) =>
|
||||||
|
context.NotificationRecipients.RemoveRange(recipients);
|
||||||
|
|
||||||
|
public void RemoveNotifications(IEnumerable<Models.Notification> notifications) =>
|
||||||
|
context.Notifications.RemoveRange(notifications);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a notification for every resolved recipient and pushes it live. Best-effort: this method
|
||||||
|
/// never throws, so a notification problem can never fail the business action that triggered it.
|
||||||
|
/// Call it only AFTER the owning service's <c>uow.CommitAsync()</c> has succeeded.
|
||||||
|
/// </summary>
|
||||||
|
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationService(
|
||||||
|
INotificationRepository repo,
|
||||||
|
NotificationRecipientResolver resolver,
|
||||||
|
IHubContext<NotificationHub> hub,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ILogger<NotificationService> logger) : INotificationService
|
||||||
|
{
|
||||||
|
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// ── Trigger side ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!NotificationTypes.All.TryGetValue(type, out var definition))
|
||||||
|
{
|
||||||
|
logger.LogError("Notification type {Type} is not registered in NotificationTypes; skipped.", type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIds = await resolver.ResolveAsync(recipients, args.CreatedByUserId);
|
||||||
|
if (userIds.Count == 0) return;
|
||||||
|
|
||||||
|
var values = ToDictionary(args.Values);
|
||||||
|
var notification = new Models.Notification
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Severity = definition.Severity,
|
||||||
|
Title = Truncate(Render(definition.TitleTemplate, values), 150),
|
||||||
|
Message = Truncate(Render(definition.MessageTemplate, values), 500),
|
||||||
|
EntityType = args.EntityType,
|
||||||
|
EntityId = args.EntityId,
|
||||||
|
Link = args.Link,
|
||||||
|
CreatedByUserId = args.CreatedByUserId,
|
||||||
|
CreatedAt = DateTime.Now,
|
||||||
|
Recipients = userIds.Select(id => new Models.NotificationRecipient { FK_UserId = id }).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await repo.AddAsync(notification);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
repo.Discard(notification);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PushAsync(notification);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create {Type} notification for {EntityType} {EntityId}.",
|
||||||
|
type, args.EntityType, args.EntityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate from persistence: a failed push still leaves the notification in the inbox.
|
||||||
|
private async Task PushAsync(Models.Notification notification)
|
||||||
|
{
|
||||||
|
foreach (var recipient in notification.Recipients)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await hub.Clients.User(recipient.FK_UserId.ToString())
|
||||||
|
.SendAsync(NotificationHub.ClientEvent, ToResponse(notification, recipient));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to push notification {NotificationId} to user {UserId}.",
|
||||||
|
notification.Id, recipient.FK_UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inbox side ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<PagedResponse<NotificationResponse>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var total = await repo.GetInboxCountAsync(userId, unreadOnly);
|
||||||
|
var rows = await repo.GetInboxAsync(userId, unreadOnly, page, pageSize);
|
||||||
|
return new PagedResponse<NotificationResponse>(page, pageSize, total,
|
||||||
|
rows.Select(r => ToResponse(r.Notification, r)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UnreadCountResponse> GetUnreadCountAsync(Guid userId) =>
|
||||||
|
new(await repo.GetUnreadCountAsync(userId));
|
||||||
|
|
||||||
|
public async Task MarkReadAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||||
|
?? throw new KeyNotFoundException("Notification not found.");
|
||||||
|
if (recipient.IsRead) return;
|
||||||
|
|
||||||
|
recipient.IsRead = true;
|
||||||
|
recipient.ReadAt = DateTime.Now;
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkAllReadAsync(Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var unread = await repo.GetUnreadRecipientsAsync(userId);
|
||||||
|
if (unread.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
foreach (var recipient in unread)
|
||||||
|
{
|
||||||
|
recipient.IsRead = true;
|
||||||
|
recipient.ReadAt = now;
|
||||||
|
}
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||||
|
?? throw new KeyNotFoundException("Notification not found.");
|
||||||
|
|
||||||
|
repo.RemoveRecipient(recipient);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static NotificationResponse ToResponse(Models.Notification n, Models.NotificationRecipient r) =>
|
||||||
|
new(r.Id, n.Type, n.Severity, n.Title, n.Message, n.EntityType, n.EntityId, n.Link,
|
||||||
|
n.CreatedAt, r.IsRead, r.ReadAt);
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ToDictionary(object? values) =>
|
||||||
|
values is null
|
||||||
|
? []
|
||||||
|
: values.GetType().GetProperties()
|
||||||
|
.ToDictionary(p => p.Name, p => p.GetValue(values)?.ToString() ?? string.Empty);
|
||||||
|
|
||||||
|
private static string Render(string template, Dictionary<string, string> values) =>
|
||||||
|
TokenPattern.Replace(template, m => values.TryGetValue(m.Groups[1].Value, out var v) ? v : string.Empty).Trim();
|
||||||
|
|
||||||
|
private static string Truncate(string value, int max) =>
|
||||||
|
value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public record NotificationTypeDefinition(NotificationSeverity Severity, string TitleTemplate, string MessageTemplate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of every notification type. Services must pass one of these constants to
|
||||||
|
/// <see cref="INotificationService.NotifyAsync"/> — never a string literal. Templates use {Token}
|
||||||
|
/// placeholders filled from <see cref="NotificationArgs.Values"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class NotificationTypes
|
||||||
|
{
|
||||||
|
public const string RideCancelled = "RideCancelled";
|
||||||
|
public const string RideDriverAssigned = "RideDriverAssigned";
|
||||||
|
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
|
||||||
|
public const string DeliveryTaskPartiallyDelivered = "DeliveryTaskPartiallyDelivered";
|
||||||
|
public const string SupplierClaimApproved = "SupplierClaimApproved";
|
||||||
|
public const string SupplierClaimRejected = "SupplierClaimRejected";
|
||||||
|
public const string ShopPOConfirmed = "ShopPOConfirmed";
|
||||||
|
public const string ShopPOCancelled = "ShopPOCancelled";
|
||||||
|
public const string StockLow = "StockLow";
|
||||||
|
public const string CreditLimitBreached = "CreditLimitBreached";
|
||||||
|
|
||||||
|
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All =
|
||||||
|
new Dictionary<string, NotificationTypeDefinition>
|
||||||
|
{
|
||||||
|
[RideCancelled] = new(NotificationSeverity.Critical,
|
||||||
|
"Ride cancelled", "A ride was cancelled ({CancelType}). {Reason}"),
|
||||||
|
[RideDriverAssigned] = new(NotificationSeverity.Info,
|
||||||
|
"Ride assigned", "You have been assigned to a ride."),
|
||||||
|
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
|
||||||
|
"Delivery failed", "Delivery to {StoreName} failed. {Note}"),
|
||||||
|
[DeliveryTaskPartiallyDelivered] = new(NotificationSeverity.Warning,
|
||||||
|
"Partial delivery", "Delivery to {StoreName} was only partly delivered."),
|
||||||
|
[SupplierClaimApproved] = new(NotificationSeverity.Success,
|
||||||
|
"Supplier claim approved", "Your supplier claim was approved."),
|
||||||
|
[SupplierClaimRejected] = new(NotificationSeverity.Warning,
|
||||||
|
"Supplier claim rejected", "Your supplier claim was rejected."),
|
||||||
|
[ShopPOConfirmed] = new(NotificationSeverity.Info,
|
||||||
|
"Shop order confirmed", "Shop order for {StoreName} was confirmed."),
|
||||||
|
[ShopPOCancelled] = new(NotificationSeverity.Warning,
|
||||||
|
"Shop order cancelled", "Shop order for {StoreName} was cancelled: {Reason}"),
|
||||||
|
[StockLow] = new(NotificationSeverity.Warning,
|
||||||
|
"Low stock", "{ItemName} is down to {Quantity} at {Location}."),
|
||||||
|
[CreditLimitBreached] = new(NotificationSeverity.Warning,
|
||||||
|
"Credit limit exceeded", "{StoreName} has exceeded its credit limit."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public enum ScopeKind { Region, Route, Store }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who a notification goes to. There is deliberately no "everyone" rule — every trigger must say who
|
||||||
|
/// needs to know. Scope coverage follows Region → Route → Store: a region scope covers its routes
|
||||||
|
/// and their stores, a route scope covers its stores.
|
||||||
|
/// </summary>
|
||||||
|
public abstract record RecipientRule
|
||||||
|
{
|
||||||
|
/// <summary>These specific users (creator, driver, approver, route owner …).</summary>
|
||||||
|
public static RecipientRule Users(params Guid[] ids) => new UsersRule(ids);
|
||||||
|
|
||||||
|
/// <summary>Every active user whose role holds the permission code.</summary>
|
||||||
|
public static RecipientRule Permission(string code) => new PermissionRule(code);
|
||||||
|
|
||||||
|
/// <summary>Every active user whose scope covers the region / route / store.</summary>
|
||||||
|
public static RecipientRule Scope(ScopeKind kind, Guid id) => new ScopeRule(kind, id);
|
||||||
|
|
||||||
|
/// <summary>Users who hold the permission AND whose scope covers the region / route / store.</summary>
|
||||||
|
public static RecipientRule PermissionInScope(string code, ScopeKind kind, Guid id) =>
|
||||||
|
new PermissionInScopeRule(code, kind, id);
|
||||||
|
|
||||||
|
/// <summary>The user's supervisor (User.FK_ReportsToUserId).</summary>
|
||||||
|
public static RecipientRule ReportsTo(Guid userId) => new ReportsToRule(userId);
|
||||||
|
|
||||||
|
/// <summary>The owner of the route (<see cref="ScopeKind.Route"/>) or of the store's route (<see cref="ScopeKind.Store"/>).</summary>
|
||||||
|
public static RecipientRule RouteOwner(ScopeKind kind, Guid id) => new RouteOwnerRule(kind, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UsersRule(IReadOnlyCollection<Guid> Ids) : RecipientRule;
|
||||||
|
public sealed record PermissionRule(string Code) : RecipientRule;
|
||||||
|
public sealed record ScopeRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
|
public sealed record PermissionInScopeRule(string Code, ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
|
public sealed record ReportsToRule(Guid UserId) : RecipientRule;
|
||||||
|
public sealed record RouteOwnerRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Features.Invoice;
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.ReceivedCheque;
|
using Distribution.Features.ReceivedCheque;
|
||||||
using Distribution.Features.StoreCredit;
|
using Distribution.Features.StoreCredit;
|
||||||
@@ -16,8 +15,7 @@ public class PaymentService(
|
|||||||
IReceivedChequeRepository receivedChequeRepo,
|
IReceivedChequeRepository receivedChequeRepo,
|
||||||
IStoreCreditMovementRepository creditRepo,
|
IStoreCreditMovementRepository creditRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||||
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
||||||
@@ -152,13 +150,14 @@ public class PaymentService(
|
|||||||
|
|
||||||
invoiceRepo.Update(invoice);
|
invoiceRepo.Update(invoice);
|
||||||
|
|
||||||
|
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||||
|
// Debit Cash/Bank (BankAccount) OR Cheques-in-hand (for Cheque payments)
|
||||||
|
// Credit Accounts Receivable (invoice.Store)
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
// Mirrors to the General Ledger after commit: Dr Cash/Bank (or Cheques-in-hand) / Cr Receivable. Never throws.
|
|
||||||
await gl.PostCustomerPaymentAsync(payment, ct);
|
|
||||||
|
|
||||||
return ToResponse(payment);
|
return ToResponse(payment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public interface IPaymentReportRepository
|
||||||
|
{
|
||||||
|
/// <summary>Active payments collected within [From, To] (whole days).</summary>
|
||||||
|
Task<List<PaymentRow>> GetPaymentsAsync(PaymentReportFilter f);
|
||||||
|
|
||||||
|
/// <summary>Finalized, non-cancelled invoices up to the end of 'To', each with cash/cheque paid as of then.</summary>
|
||||||
|
Task<List<InvoiceBalanceRow>> GetInvoiceBalancesAsync(PaymentReportFilter f);
|
||||||
|
|
||||||
|
/// <summary>Sum of TotalAmount of invoices finalized within [From, To].</summary>
|
||||||
|
Task<decimal> GetInvoicedAsync(PaymentReportFilter f);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports/payments")]
|
||||||
|
public class PaymentReportController(PaymentReportService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("analytics")]
|
||||||
|
public async Task<IActionResult> Analytics(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] Guid? regionId)
|
||||||
|
{
|
||||||
|
var f = PaymentReportService.BuildFilter(from, to, regionId);
|
||||||
|
var error = PaymentReportService.Validate(f);
|
||||||
|
return error is null ? Ok(await service.GetAnalyticsAsync(f)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public record PaymentReportFilter(DateTime From, DateTime To, Guid? RegionId);
|
||||||
|
|
||||||
|
public record PaymentSummary(
|
||||||
|
decimal Invoiced,
|
||||||
|
decimal Collected,
|
||||||
|
decimal CreditExtended,
|
||||||
|
decimal Outstanding,
|
||||||
|
decimal Overdue,
|
||||||
|
decimal? CollectionRatePct,
|
||||||
|
int PaymentCount);
|
||||||
|
|
||||||
|
public record MethodBreakdown(string Method, int Count, decimal Amount, decimal SharePct);
|
||||||
|
|
||||||
|
public record PaymentTrendPoint(string Period, decimal Cash, decimal Cheque, decimal Credit);
|
||||||
|
|
||||||
|
public record AgingBucket(string Bucket, int Invoices, decimal Balance);
|
||||||
|
|
||||||
|
public record StoreOutstanding(Guid StoreId, string StoreName, int Invoices, decimal Balance, decimal Overdue);
|
||||||
|
|
||||||
|
public record AccountCollection(Guid? AccountId, string AccountName, int Count, decimal Amount);
|
||||||
|
|
||||||
|
public record ChequePipeline(string Status, int Count, decimal Amount);
|
||||||
|
|
||||||
|
public record PaymentAnalyticsResponse(
|
||||||
|
DateTime From,
|
||||||
|
DateTime To,
|
||||||
|
PaymentSummary Summary,
|
||||||
|
List<MethodBreakdown> ByMethod,
|
||||||
|
List<PaymentTrendPoint> Trend,
|
||||||
|
List<AgingBucket> Aging,
|
||||||
|
List<StoreOutstanding> TopOutstanding,
|
||||||
|
List<AccountCollection> ByAccount,
|
||||||
|
List<ChequePipeline> Cheques);
|
||||||
|
|
||||||
|
// Repository row shapes
|
||||||
|
public record PaymentRow(PaymentMethod Method, decimal Amount, DateTime CollectedAt, Guid? AccountId, string? AccountName, string? ChequeStatus);
|
||||||
|
|
||||||
|
public record InvoiceBalanceRow(Guid StoreId, string StoreName, DateTime FinalizedAt, decimal Total, decimal Paid);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public class PaymentReportRepository(AppDBContext db) : IPaymentReportRepository
|
||||||
|
{
|
||||||
|
public async Task<List<PaymentRow>> GetPaymentsAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Payments.AsNoTracking()
|
||||||
|
.Where(p => p.IsActive && p.CollectedAt >= start && p.CollectedAt < end
|
||||||
|
&& p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
q = q.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
|
||||||
|
return await q.Select(p => new PaymentRow(
|
||||||
|
p.Method, p.Amount, p.CollectedAt,
|
||||||
|
p.FK_BankAccountId,
|
||||||
|
p.BankAccount == null ? null : p.BankAccount.AccountName,
|
||||||
|
p.ReceivedCheque == null ? null : p.ReceivedCheque.Status.ToString()))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<InvoiceBalanceRow>> GetInvoiceBalancesAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Invoices.AsNoTracking()
|
||||||
|
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||||
|
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
|
||||||
|
// Credit payments do not reduce the receivable (same rule as the dashboard).
|
||||||
|
return await q.Select(i => new InvoiceBalanceRow(
|
||||||
|
i.ShopPO.Store.Id,
|
||||||
|
i.ShopPO.Store.StoreName,
|
||||||
|
i.FinalizedAt!.Value,
|
||||||
|
i.TotalAmount,
|
||||||
|
i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||||
|
.Sum(p => (decimal?)p.Amount) ?? 0m))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetInvoicedAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.Invoices.AsNoTracking()
|
||||||
|
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||||
|
&& i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||||
|
if (f.RegionId.HasValue)
|
||||||
|
q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
return await q.SumAsync(i => (decimal?)i.TotalAmount) ?? 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PaymentReport;
|
||||||
|
|
||||||
|
public class PaymentReportService(IPaymentReportRepository repo)
|
||||||
|
{
|
||||||
|
public const int MaxRangeDays = 366;
|
||||||
|
private const int DailyGranularityMaxDays = 92;
|
||||||
|
private const int OverdueDays = 30; // matches the dashboard receivables definition
|
||||||
|
private const int TopStores = 10;
|
||||||
|
|
||||||
|
public static PaymentReportFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId)
|
||||||
|
{
|
||||||
|
var end = (to ?? DateTime.Now).Date;
|
||||||
|
var start = (from ?? end.AddDays(-29)).Date;
|
||||||
|
return new PaymentReportFilter(start, end, regionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Validate(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
if (f.To < f.From) return "'to' must not be before 'from'.";
|
||||||
|
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal Pct(decimal part, decimal whole) => whole > 0 ? Math.Round(part / whole * 100, 2) : 0m;
|
||||||
|
|
||||||
|
public async Task<PaymentAnalyticsResponse> GetAnalyticsAsync(PaymentReportFilter f)
|
||||||
|
{
|
||||||
|
var payments = await repo.GetPaymentsAsync(f);
|
||||||
|
var balances = await repo.GetInvoiceBalancesAsync(f);
|
||||||
|
var invoiced = await repo.GetInvoicedAsync(f);
|
||||||
|
var asOf = f.To.Date.AddDays(1);
|
||||||
|
|
||||||
|
var collected = payments.Where(p => p.Method != PaymentMethod.Credit).Sum(p => p.Amount);
|
||||||
|
var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount);
|
||||||
|
var total = payments.Sum(p => p.Amount);
|
||||||
|
|
||||||
|
var open = balances
|
||||||
|
.Select(b => (b.StoreId, b.StoreName, Age: (asOf - b.FinalizedAt).TotalDays, Balance: Math.Max(0m, b.Total - b.Paid)))
|
||||||
|
.Where(b => b.Balance > 0)
|
||||||
|
.ToList();
|
||||||
|
var outstanding = open.Sum(b => b.Balance);
|
||||||
|
var overdue = open.Where(b => b.Age > OverdueDays).Sum(b => b.Balance);
|
||||||
|
|
||||||
|
var summary = new PaymentSummary(
|
||||||
|
invoiced, collected, credit, outstanding, overdue,
|
||||||
|
invoiced > 0 ? Math.Round(collected / invoiced * 100, 2) : null,
|
||||||
|
payments.Count);
|
||||||
|
|
||||||
|
var byMethod = Enum.GetValues<PaymentMethod>().Select(m =>
|
||||||
|
{
|
||||||
|
var rows = payments.Where(p => p.Method == m).ToList();
|
||||||
|
var amount = rows.Sum(p => p.Amount);
|
||||||
|
return new MethodBreakdown(m.ToString(), rows.Count, amount, Pct(amount, total));
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var daily = (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
|
||||||
|
var trend = payments
|
||||||
|
.GroupBy(p => daily ? p.CollectedAt.ToString("yyyy-MM-dd") : p.CollectedAt.ToString("yyyy-MM"))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => new PaymentTrendPoint(g.Key,
|
||||||
|
g.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount),
|
||||||
|
g.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount),
|
||||||
|
g.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var buckets = new (string Name, Func<double, bool> In)[]
|
||||||
|
{
|
||||||
|
("0-30 days", a => a <= 30),
|
||||||
|
("31-60 days", a => a > 30 && a <= 60),
|
||||||
|
("61-90 days", a => a > 60 && a <= 90),
|
||||||
|
("90+ days", a => a > 90),
|
||||||
|
};
|
||||||
|
var aging = buckets.Select(b =>
|
||||||
|
{
|
||||||
|
var rows = open.Where(o => b.In(o.Age)).ToList();
|
||||||
|
return new AgingBucket(b.Name, rows.Count, rows.Sum(r => r.Balance));
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var topOutstanding = open
|
||||||
|
.GroupBy(o => (o.StoreId, o.StoreName))
|
||||||
|
.Select(g => new StoreOutstanding(g.Key.StoreId, g.Key.StoreName, g.Count(),
|
||||||
|
g.Sum(x => x.Balance), g.Where(x => x.Age > OverdueDays).Sum(x => x.Balance)))
|
||||||
|
.OrderByDescending(s => s.Balance)
|
||||||
|
.Take(TopStores)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var byAccount = payments
|
||||||
|
.Where(p => p.Method == PaymentMethod.Cash)
|
||||||
|
.GroupBy(p => (p.AccountId, Name: p.AccountName ?? "Unassigned"))
|
||||||
|
.Select(g => new AccountCollection(g.Key.AccountId, g.Key.Name, g.Count(), g.Sum(p => p.Amount)))
|
||||||
|
.OrderByDescending(a => a.Amount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var cheques = payments
|
||||||
|
.Where(p => p.Method == PaymentMethod.Cheque)
|
||||||
|
.GroupBy(p => p.ChequeStatus ?? "Unknown")
|
||||||
|
.Select(g => new ChequePipeline(g.Key, g.Count(), g.Sum(p => p.Amount)))
|
||||||
|
.OrderByDescending(c => c.Amount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PaymentAnalyticsResponse(f.From, f.To, summary, byMethod, trend, aging, topOutstanding, byAccount, cheques);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
return (rows, total);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||||
{
|
{
|
||||||
var lines =
|
var lines =
|
||||||
from li in db.ShopPOItems.AsNoTracking()
|
from li in db.InvoiceItems.AsNoTracking()
|
||||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
where li.IsActive && !li.IsRemoved
|
||||||
where inv.Status == InvoiceStatus.Finalized
|
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||||
select li;
|
select li;
|
||||||
|
|
||||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
if (f.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
if (f.RouteId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||||
{
|
{
|
||||||
var s = f.Search.Trim();
|
var s = f.Search.Trim();
|
||||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||||
|
|
||||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
// Distinct orders and stores per item are counted separately: MySQL can't run the correlated
|
||||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||||
var storePairs = await SoldLines(f)
|
var pairs = await SoldLines(f)
|
||||||
.Where(l => ids.Contains(l.FK_ItemId))
|
.Where(l => ids.Contains(l.FK_ItemId))
|
||||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
var orderCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(p => p.PoId).Distinct().Count());
|
||||||
|
var storeCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(p => p.FK_StoreId).Distinct().Count());
|
||||||
|
|
||||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
|
||||||
|
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||||
return (rows, total);
|
return (rows, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
SoldLines(f).SumAsync(x => x.Quantity * x.UnitPrice);
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||||
{
|
{
|
||||||
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
|||||||
return await SoldLines(f with { Search = null })
|
return await SoldLines(f with { Search = null })
|
||||||
.Where(l => ids.Contains(l.FK_ItemId))
|
.Where(l => ids.Contains(l.FK_ItemId))
|
||||||
.GroupBy(l => l.FK_ItemId)
|
.GroupBy(l => l.FK_ItemId)
|
||||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.Quantity) })
|
||||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.ChequeBook;
|
using Distribution.Features.ChequeBook;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
@@ -9,8 +8,7 @@ namespace Distribution.Features.ReceivedCheque;
|
|||||||
public class ReceivedChequeService(
|
public class ReceivedChequeService(
|
||||||
IReceivedChequeRepository repo,
|
IReceivedChequeRepository repo,
|
||||||
IBankAccountRepository bankAccountRepo,
|
IBankAccountRepository bankAccountRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
||||||
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
||||||
@@ -112,7 +110,6 @@ public class ReceivedChequeService(
|
|||||||
|
|
||||||
repo.Update(cheque);
|
repo.Update(cheque);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
await gl.PostReceivedChequeAsync(cheque, request.Action, ct);
|
|
||||||
return ToResponse(cheque);
|
return ToResponse(cheque);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public interface IRecurringExpenseRepository
|
||||||
|
{
|
||||||
|
Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize);
|
||||||
|
Task<int> CountAsync();
|
||||||
|
Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf);
|
||||||
|
Task AddAsync(Models.RecurringExpenseTemplate entity);
|
||||||
|
void Update(Models.RecurringExpenseTemplate entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public record ToggleRequest(bool Paused);
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/recurring-expenses")]
|
||||||
|
public class RecurringExpenseController(RecurringExpenseService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20) =>
|
||||||
|
Ok(await service.ListAsync(page, pageSize));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> Create(CreateRecurringExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/toggle")]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> Toggle(Guid id, ToggleRequest body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.ToggleAsync(id, body.Paused, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("run-due")]
|
||||||
|
[Authorize("expense.manage_recurring")]
|
||||||
|
public async Task<IActionResult> RunDue(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.RunDueAsync(ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public record CreateRecurringExpenseRequest(
|
||||||
|
string Name,
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
Guid? VendorId,
|
||||||
|
decimal Amount,
|
||||||
|
string? Payee,
|
||||||
|
string? Description,
|
||||||
|
RecurringFrequency Frequency,
|
||||||
|
int? DayOfMonth,
|
||||||
|
int? DayOfWeek,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
bool AutoPost);
|
||||||
|
|
||||||
|
public record UpdateRecurringExpenseRequest(
|
||||||
|
string Name,
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
Guid? VendorId,
|
||||||
|
decimal Amount,
|
||||||
|
string? Payee,
|
||||||
|
string? Description,
|
||||||
|
RecurringFrequency Frequency,
|
||||||
|
int? DayOfMonth,
|
||||||
|
int? DayOfWeek,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
bool AutoPost);
|
||||||
|
|
||||||
|
public record RecurringExpenseResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
Guid CategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
string? WarehouseName,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? VendorName,
|
||||||
|
decimal Amount,
|
||||||
|
string? Payee,
|
||||||
|
string? Description,
|
||||||
|
RecurringFrequency Frequency,
|
||||||
|
int? DayOfMonth,
|
||||||
|
int? DayOfWeek,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
DateTime NextRunDate,
|
||||||
|
bool AutoPost,
|
||||||
|
bool IsPaused,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PagedRecurringExpensesResponse(List<RecurringExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||||
|
|
||||||
|
public record RunDueResponse(int Generated, List<Guid> ExpenseIds);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public class RecurringExpenseRepository(AppDBContext context) : IRecurringExpenseRepository
|
||||||
|
{
|
||||||
|
private IQueryable<Models.RecurringExpenseTemplate> Base() =>
|
||||||
|
context.RecurringExpenseTemplates
|
||||||
|
.Include(t => t.Category)
|
||||||
|
.Include(t => t.Warehouse)
|
||||||
|
.Include(t => t.Vendor);
|
||||||
|
|
||||||
|
public async Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id) =>
|
||||||
|
await Base().FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize) =>
|
||||||
|
await Base()
|
||||||
|
.Where(t => t.IsActive)
|
||||||
|
.OrderBy(t => t.IsPaused).ThenBy(t => t.NextRunDate)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync() =>
|
||||||
|
await context.RecurringExpenseTemplates.CountAsync(t => t.IsActive);
|
||||||
|
|
||||||
|
public async Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf) =>
|
||||||
|
await Base()
|
||||||
|
.Where(t => t.IsActive && !t.IsPaused && t.NextRunDate <= asOf
|
||||||
|
&& (t.EndDate == null || t.EndDate >= asOf))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.RecurringExpenseTemplate entity) =>
|
||||||
|
await context.RecurringExpenseTemplates.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.RecurringExpenseTemplate entity) =>
|
||||||
|
context.RecurringExpenseTemplates.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.RecurringExpense;
|
||||||
|
|
||||||
|
public class RecurringExpenseService(
|
||||||
|
AppDBContext context,
|
||||||
|
IRecurringExpenseRepository repo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private static RecurringExpenseResponse Map(Models.RecurringExpenseTemplate t) =>
|
||||||
|
new(t.Id, t.Name,
|
||||||
|
t.FK_CategoryId, t.Category?.Name ?? string.Empty,
|
||||||
|
t.FK_WarehouseId, t.Warehouse?.Name,
|
||||||
|
t.FK_VendorId, t.Vendor?.Name,
|
||||||
|
t.Amount, t.Payee, t.Description,
|
||||||
|
t.Frequency, t.DayOfMonth, t.DayOfWeek,
|
||||||
|
t.StartDate, t.EndDate, t.NextRunDate, t.AutoPost, t.IsPaused, t.IsActive, t.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedRecurringExpensesResponse> ListAsync(int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||||
|
var items = await repo.ListAsync(page, pageSize);
|
||||||
|
var total = await repo.CountAsync();
|
||||||
|
return new PagedRecurringExpensesResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecurringExpenseResponse> CreateAsync(CreateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
|
||||||
|
var category = await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||||
|
?? throw new InvalidOperationException("Category not found");
|
||||||
|
if (category.FK_ParentCategoryId is null)
|
||||||
|
throw new InvalidOperationException("Pick a specific title under a category");
|
||||||
|
|
||||||
|
var entity = new Models.RecurringExpenseTemplate
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
FK_CategoryId = request.CategoryId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
FK_VendorId = request.VendorId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
Payee = request.Payee,
|
||||||
|
Description = request.Description,
|
||||||
|
Frequency = request.Frequency,
|
||||||
|
DayOfMonth = request.DayOfMonth,
|
||||||
|
DayOfWeek = request.DayOfWeek,
|
||||||
|
StartDate = request.StartDate.Date,
|
||||||
|
EndDate = request.EndDate?.Date,
|
||||||
|
NextRunDate = request.StartDate.Date,
|
||||||
|
AutoPost = request.AutoPost
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map((await repo.GetByIdAsync(entity.Id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecurringExpenseResponse?> UpdateAsync(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
|
||||||
|
entity.Name = request.Name.Trim();
|
||||||
|
entity.FK_CategoryId = request.CategoryId;
|
||||||
|
entity.FK_WarehouseId = request.WarehouseId;
|
||||||
|
entity.FK_VendorId = request.VendorId;
|
||||||
|
entity.Amount = request.Amount;
|
||||||
|
entity.Payee = request.Payee;
|
||||||
|
entity.Description = request.Description;
|
||||||
|
entity.Frequency = request.Frequency;
|
||||||
|
entity.DayOfMonth = request.DayOfMonth;
|
||||||
|
entity.DayOfWeek = request.DayOfWeek;
|
||||||
|
entity.StartDate = request.StartDate.Date;
|
||||||
|
entity.EndDate = request.EndDate?.Date;
|
||||||
|
entity.AutoPost = request.AutoPost;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecurringExpenseResponse?> ToggleAsync(Guid id, bool paused, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
entity.IsPaused = paused;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes every active, non-paused template whose NextRunDate ≤ today
|
||||||
|
/// into an Expense row, then advances NextRunDate by one period. Idempotent
|
||||||
|
/// per call — advances only after a successful insert.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RunDueResponse> RunDueAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var due = await repo.ListDueAsync(today);
|
||||||
|
if (due.Count == 0) return new RunDueResponse(0, []);
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var createdIds = new List<Guid>();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var t in due)
|
||||||
|
{
|
||||||
|
var expense = new Models.Expense
|
||||||
|
{
|
||||||
|
FK_CategoryId = t.FK_CategoryId,
|
||||||
|
FK_WarehouseId = t.FK_WarehouseId,
|
||||||
|
FK_VendorId = t.FK_VendorId,
|
||||||
|
Amount = t.Amount,
|
||||||
|
BillDate = t.NextRunDate,
|
||||||
|
PeriodCovered = new DateTime(t.NextRunDate.Year, t.NextRunDate.Month, 1),
|
||||||
|
Payee = t.Payee,
|
||||||
|
Description = t.Description,
|
||||||
|
Status = t.AutoPost ? ExpenseStatus.Posted : ExpenseStatus.Draft,
|
||||||
|
RecordedByUserId = userId,
|
||||||
|
PostedByUserId = t.AutoPost ? userId : null,
|
||||||
|
PostedAt = t.AutoPost ? DateTime.Now : null,
|
||||||
|
FK_RecurringTemplateId = t.Id
|
||||||
|
};
|
||||||
|
await context.Expenses.AddAsync(expense, ct);
|
||||||
|
createdIds.Add(expense.Id);
|
||||||
|
|
||||||
|
t.NextRunDate = AdvanceNext(t.NextRunDate, t.Frequency);
|
||||||
|
repo.Update(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new RunDueResponse(createdIds.Count, createdIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime AdvanceNext(DateTime from, RecurringFrequency freq) => freq switch
|
||||||
|
{
|
||||||
|
RecurringFrequency.Weekly => from.AddDays(7),
|
||||||
|
RecurringFrequency.Monthly => from.AddMonths(1),
|
||||||
|
RecurringFrequency.Quarterly => from.AddMonths(3),
|
||||||
|
RecurringFrequency.Yearly => from.AddYears(1),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown frequency {freq}")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void ValidateSchedule(RecurringFrequency freq, int? dayOfMonth, int? dayOfWeek)
|
||||||
|
{
|
||||||
|
if (freq == RecurringFrequency.Weekly)
|
||||||
|
{
|
||||||
|
if (dayOfWeek is null or < 0 or > 6)
|
||||||
|
throw new InvalidOperationException("DayOfWeek (0-6) is required for Weekly");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (dayOfMonth is null or < 1 or > 31)
|
||||||
|
throw new InvalidOperationException("DayOfMonth (1-31) is required for Monthly/Quarterly/Yearly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.DeliveryRide;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -10,9 +12,14 @@ public class RideService(
|
|||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IDriverProfileRepository driverRepo,
|
IDriverProfileRepository driverRepo,
|
||||||
IDriverComplianceConfigRepository complianceRepo,
|
IDriverComplianceConfigRepository complianceRepo,
|
||||||
|
IDeliveryTaskRepository taskRepo,
|
||||||
StockMover mover,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
|
ILogger<RideService> logger,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string RidesLink = "/operations/rides";
|
||||||
|
|
||||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||||
{
|
{
|
||||||
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||||
@@ -72,6 +79,12 @@ public class RideService(
|
|||||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.RideDriverAssigned,
|
||||||
|
[RecipientRule.Users(driver.FK_UserId)],
|
||||||
|
new NotificationArgs(null, "Ride", rideId, RidesLink),
|
||||||
|
ct);
|
||||||
|
|
||||||
var saved = await rideRepo.GetByIdAsync(rideId);
|
var saved = await rideRepo.GetByIdAsync(rideId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -125,9 +138,46 @@ public class RideService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyRideCancelledAsync(ride, request, userId, ct);
|
||||||
|
|
||||||
return ToResponse(ride);
|
return ToResponse(ride);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A ride has no route of its own; the people to tell are the owners and managers of the routes
|
||||||
|
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
|
||||||
|
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
|
||||||
|
var routeIds = tasks
|
||||||
|
.Where(t => t.ShopPO?.Store is not null)
|
||||||
|
.Select(t => t.ShopPO.Store.FK_RouteId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var recipients = new List<RecipientRule>();
|
||||||
|
foreach (var routeId in routeIds)
|
||||||
|
{
|
||||||
|
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
|
||||||
|
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
|
||||||
|
}
|
||||||
|
if (recipients.Count == 0) return;
|
||||||
|
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.RideCancelled,
|
||||||
|
recipients,
|
||||||
|
new NotificationArgs(
|
||||||
|
new { CancelType = request.TypeCode, Reason = request.Reason },
|
||||||
|
"Ride", ride.Id, RidesLink, userId),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||||
{
|
{
|
||||||
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public interface ISchemeRepository
|
|||||||
|
|
||||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||||
|
Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId);
|
||||||
|
void RemoveRedemption(Models.SchemeRedemption entity);
|
||||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||||
|
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||||
|
|
||||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ public class SchemeController(SchemeService service) : ControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("suggest")]
|
||||||
|
[Authorize("scheme.view")]
|
||||||
|
public async Task<IActionResult> Suggest(SuggestSchemesRequest request)
|
||||||
|
{
|
||||||
|
var result = await service.SuggestAsync(request);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/spend")]
|
[HttpGet("{id:guid}/spend")]
|
||||||
[Authorize("scheme.view")]
|
[Authorize("scheme.view")]
|
||||||
public async Task<IActionResult> GetSpend(Guid id)
|
public async Task<IActionResult> GetSpend(Guid id)
|
||||||
|
|||||||
@@ -94,7 +94,13 @@ public record SchemeEligibilityResponse(
|
|||||||
string? Reason,
|
string? Reason,
|
||||||
decimal? DiscountAmount,
|
decimal? DiscountAmount,
|
||||||
Guid? FreeItemId,
|
Guid? FreeItemId,
|
||||||
decimal? FreeQty);
|
decimal? FreeQty,
|
||||||
|
string? FreeItemName = null);
|
||||||
|
|
||||||
|
/// <summary>A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list.</summary>
|
||||||
|
public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false);
|
||||||
|
|
||||||
|
public record SuggestSchemesRequest(Guid StoreId, List<SuggestLine> Items);
|
||||||
|
|
||||||
public record SchemeRedemptionResponse(
|
public record SchemeRedemptionResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -49,9 +49,25 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
|||||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||||
await context.SchemeRedemptions.AddAsync(entity);
|
await context.SchemeRedemptions.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId)
|
||||||
|
{
|
||||||
|
var list = await context.SchemeRedemptions.Include(r => r.Scheme).Where(r => r.FK_ShopPOId == shopPOId).ToListAsync();
|
||||||
|
// Rows added earlier in this unit of work are not in the database yet; rows removed are still there.
|
||||||
|
foreach (var added in context.SchemeRedemptions.Local.Where(r => r.FK_ShopPOId == shopPOId && !list.Contains(r)))
|
||||||
|
list.Add(added);
|
||||||
|
return list.Where(r => context.Entry(r).State != EntityState.Deleted).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveRedemption(Models.SchemeRedemption entity) =>
|
||||||
|
context.SchemeRedemptions.Remove(entity);
|
||||||
|
|
||||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||||
|
|
||||||
|
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
|
||||||
|
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
|
||||||
|
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
|
||||||
|
|
||||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||||
await context.SchemeBundleComponents
|
await context.SchemeBundleComponents
|
||||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
@@ -14,6 +16,8 @@ public class SchemeService(
|
|||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStoreRepository storeRepo,
|
IStoreRepository storeRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
|
IInvoiceRepository invoiceRepo,
|
||||||
|
PriceListService priceListService,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
@@ -261,12 +265,7 @@ public class SchemeService(
|
|||||||
|
|
||||||
foreach (var scheme in candidates)
|
foreach (var scheme in candidates)
|
||||||
{
|
{
|
||||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||||
continue;
|
|
||||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
|
||||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
result.Add(EvaluateRule(scheme, items));
|
result.Add(EvaluateRule(scheme, items));
|
||||||
@@ -275,6 +274,328 @@ public class SchemeService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsInScope(Models.Scheme scheme, string? storeSegment, Guid? storeRegionId, DateTime asOf)
|
||||||
|
{
|
||||||
|
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != storeRegionId) return false;
|
||||||
|
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||||
|
!string.Equals(scheme.EligibleSegment, storeSegment, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
return asOf >= scheme.DateRangeFrom && asOf.Date <= scheme.DateRangeTo.Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggestions for a Shop PO that is still being built (nothing saved yet). Returns eligible schemes,
|
||||||
|
/// plus BuyXGetY/FreeBundle schemes the cart is part-way to, so the UI can nudge ("add 4 more").
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<SchemeEligibilityResponse>?> SuggestAsync(SuggestSchemesRequest request)
|
||||||
|
{
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||||
|
if (store is null || !store.IsActive) return null;
|
||||||
|
|
||||||
|
var lines = new List<ShopPOItem>();
|
||||||
|
foreach (var l in request.Items.Where(i => !i.IsFree))
|
||||||
|
{
|
||||||
|
var price = l.UnitPrice
|
||||||
|
?? (await priceListService.ResolveByStoreAsync(l.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||||
|
?? 0m;
|
||||||
|
lines.Add(new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = price });
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionId = store.Route?.FK_RegionId;
|
||||||
|
var inCart = lines.Select(l => l.FK_ItemId).ToHashSet();
|
||||||
|
var result = new List<SchemeEligibilityResponse>();
|
||||||
|
|
||||||
|
foreach (var scheme in (await repo.GetAllAsync()).Where(s => s.IsActive))
|
||||||
|
{
|
||||||
|
if (!IsInScope(scheme, store.Segment, regionId, DateTime.Now)) continue;
|
||||||
|
|
||||||
|
var eval = EvaluateRule(scheme, lines);
|
||||||
|
var relevant = eval.Eligible || scheme.RuleType switch
|
||||||
|
{
|
||||||
|
SchemeRuleType.BuyXGetY => scheme.FK_TriggerItemId.HasValue && inCart.Contains(scheme.FK_TriggerItemId.Value),
|
||||||
|
SchemeRuleType.FreeBundle => scheme.BundleComponents.Any(c => inCart.Contains(c.FK_ItemId)),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
if (!relevant) continue;
|
||||||
|
|
||||||
|
result.Add(eval with { FreeItemName = scheme.FreeItem?.Name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the free lines of a Shop PO that is about to be saved: each must reference an in-scope scheme the
|
||||||
|
/// paid lines still qualify for, be the scheme's free item, stay within the entitled qty, and be in stock.
|
||||||
|
/// Stamps the scheme-name snapshot and forces UnitPrice to 0. Read-only; throws InvalidOperationException.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines)
|
||||||
|
{
|
||||||
|
var freeLines = lines.Where(l => l.IsFree).ToList();
|
||||||
|
if (freeLines.Count == 0) return;
|
||||||
|
|
||||||
|
if (freeLines.Any(l => l.FK_SchemeId is null))
|
||||||
|
throw new InvalidOperationException("A free-issue line must reference the scheme that granted it.");
|
||||||
|
if (freeLines.Any(l => l.Quantity <= 0))
|
||||||
|
throw new InvalidOperationException("Free-issue quantity must be greater than zero (remove the line instead).");
|
||||||
|
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||||
|
?? throw new InvalidOperationException("Store not found.");
|
||||||
|
var regionId = store.Route?.FK_RegionId;
|
||||||
|
|
||||||
|
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(group.Key);
|
||||||
|
if (scheme is null || !scheme.IsActive)
|
||||||
|
throw new InvalidOperationException("A free-issue line references a scheme that no longer exists.");
|
||||||
|
if (!IsInScope(scheme, store.Segment, regionId, asOf))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||||
|
|
||||||
|
var eval = EvaluateRule(scheme, lines);
|
||||||
|
if (!eval.Eligible || eval.FreeItemId is null || eval.FreeQty is null)
|
||||||
|
throw new InvalidOperationException(eval.Reason ?? $"Order no longer qualifies for scheme '{scheme.Name}'.");
|
||||||
|
if (group.Any(l => l.FK_ItemId != eval.FreeItemId))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' grants a different free item.");
|
||||||
|
|
||||||
|
var qty = group.Sum(l => l.Quantity);
|
||||||
|
if (qty > eval.FreeQty.Value)
|
||||||
|
throw new InvalidOperationException($"Free quantity {qty} exceeds the {eval.FreeQty.Value:0.##} entitled by scheme '{scheme.Name}'.");
|
||||||
|
|
||||||
|
var available = (await stockRepo.GetBalancesByItemAsync(eval.FreeItemId.Value)).Sum(b => b.Quantity);
|
||||||
|
if (available < qty)
|
||||||
|
throw new InvalidOperationException($"Insufficient stock to give {qty} free of scheme '{scheme.Name}'.");
|
||||||
|
|
||||||
|
foreach (var l in group)
|
||||||
|
{
|
||||||
|
l.UnitPrice = 0m;
|
||||||
|
l.SchemeName = scheme.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the percentage-discount schemes (FlatPercent / SlabDiscount) the user accepted for a Shop PO that is
|
||||||
|
/// about to be saved: one at most, in scope, and the paid lines must still qualify. Read-only; throws.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines, List<Guid>? schemeIds)
|
||||||
|
{
|
||||||
|
if (schemeIds is not { Count: > 0 }) return;
|
||||||
|
if (schemeIds.Distinct().Count() > 1)
|
||||||
|
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||||
|
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(storeId)
|
||||||
|
?? throw new InvalidOperationException("Store not found.");
|
||||||
|
|
||||||
|
var scheme = await repo.GetByIdAsync(schemeIds[0]);
|
||||||
|
if (scheme is null || !scheme.IsActive)
|
||||||
|
throw new InvalidOperationException("The selected discount scheme no longer exists.");
|
||||||
|
if (scheme.RuleType is not (SchemeRuleType.FlatPercent or SchemeRuleType.SlabDiscount))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not a discount scheme.");
|
||||||
|
if (!IsInScope(scheme, store.Segment, store.Route?.FK_RegionId, asOf))
|
||||||
|
throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date.");
|
||||||
|
|
||||||
|
var eval = EvaluateRule(scheme, lines);
|
||||||
|
if (!eval.Eligible)
|
||||||
|
throw new InvalidOperationException(eval.Reason ?? $"Order does not qualify for scheme '{scheme.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-evaluates the percentage discounts redeemed against a Shop PO for the given (invoice) lines and returns
|
||||||
|
/// their total. With persist = true the redemption amounts are updated too (the caller commits), so spend/ROI and
|
||||||
|
/// the invoice stay in step; a discount the lines no longer qualify for drops to 0 and comes back if they requalify.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<decimal> CalculateDiscountAsync(Guid shopPOId, IEnumerable<ShopPOItem> lines, bool persist)
|
||||||
|
{
|
||||||
|
var priced = lines.ToList();
|
||||||
|
decimal total = 0m;
|
||||||
|
foreach (var r in (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue))
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||||
|
var amount = eval is { Eligible: true, DiscountAmount: not null } ? eval.DiscountAmount.Value : 0m;
|
||||||
|
if (persist) r.DiscountAmount = amount;
|
||||||
|
total += amount;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many free units each scheme currently entitles for these paid lines (0 when no longer eligible).
|
||||||
|
private async Task<Dictionary<Guid, int>> FreeEntitlementsAsync(IEnumerable<Guid> schemeIds, List<ShopPOItem> paidLines)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<Guid, int>();
|
||||||
|
foreach (var id in schemeIds.Distinct())
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(id);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, paidLines);
|
||||||
|
result[id] = eval is { Eligible: true, FreeQty: not null } ? (int)Math.Floor(eval.FreeQty.Value) : 0;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Free-issue entitlement per scheme for unsaved invoice lines (editor preview). Lines carry IsFree / scheme id;
|
||||||
|
/// only the paid ones count toward the scheme rules.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Dictionary<Guid, int>> GetFreeEntitlementsAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines) =>
|
||||||
|
await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps an invoice's free-issue lines within what the remaining paid lines still earn: when the paid quantity of the
|
||||||
|
/// qualifying item is lowered (or removed), the free quantity is reduced to match (a line that earns nothing is struck
|
||||||
|
/// out) and the PO's redemption is resized. Stages changes only. Returns true if any free line was changed.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List<InvoiceItem> lines)
|
||||||
|
{
|
||||||
|
var freeLines = lines.Where(l => l.IsActive && l.IsFree && l.FK_SchemeId.HasValue).ToList();
|
||||||
|
if (freeLines.Count == 0) return false;
|
||||||
|
|
||||||
|
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||||
|
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||||
|
.ToList();
|
||||||
|
var entitled = await FreeEntitlementsAsync(freeLines.Select(l => l.FK_SchemeId!.Value), paid);
|
||||||
|
var redemptions = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value))
|
||||||
|
{
|
||||||
|
var remaining = entitled[group.Key];
|
||||||
|
foreach (var line in group.Where(l => !l.IsRemoved).OrderBy(l => l.CreatedAt))
|
||||||
|
{
|
||||||
|
if (line.Quantity <= remaining) { remaining -= line.Quantity; continue; }
|
||||||
|
|
||||||
|
if (remaining == 0) line.IsRemoved = true;
|
||||||
|
else line.Quantity = remaining;
|
||||||
|
remaining = 0;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var redemption = redemptions.FirstOrDefault(r => r.FK_SchemeId == group.Key && r.FK_FreeItemId.HasValue);
|
||||||
|
if (redemption is not null)
|
||||||
|
redemption.FreeQty = group.Where(l => !l.IsRemoved).Sum(l => l.Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discount the given schemes would give for these lines, without touching any redemption (editor preview).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<decimal> CalculateDiscountForSchemesAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines)
|
||||||
|
{
|
||||||
|
var priced = lines.ToList();
|
||||||
|
decimal total = 0m;
|
||||||
|
foreach (var id in schemeIds.Distinct())
|
||||||
|
{
|
||||||
|
var scheme = await repo.GetByIdAsync(id);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, priced);
|
||||||
|
if (eval is { Eligible: true, DiscountAmount: not null }) total += eval.DiscountAmount.Value;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the discount choice made on a Draft invoice (the percentage scheme the user accepted, or none) to the
|
||||||
|
/// PO's redemptions: newly chosen schemes are validated and added, unchosen ones removed. The amount itself is
|
||||||
|
/// then set by CalculateDiscountAsync when the totals are re-derived. Stages changes only. Null = leave as is.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List<InvoiceItem> lines, List<Guid>? schemeIds)
|
||||||
|
{
|
||||||
|
if (schemeIds is null) return;
|
||||||
|
if (schemeIds.Distinct().Count() > 1)
|
||||||
|
throw new InvalidOperationException("Only one discount scheme can be applied to an order.");
|
||||||
|
|
||||||
|
var existing = (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue).ToList();
|
||||||
|
foreach (var r in existing.Where(r => !schemeIds.Contains(r.FK_SchemeId)))
|
||||||
|
repo.RemoveRedemption(r);
|
||||||
|
|
||||||
|
var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree)
|
||||||
|
.Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
|
||||||
|
.ToList();
|
||||||
|
var added = schemeIds.Where(id => existing.All(r => r.FK_SchemeId != id)).ToList();
|
||||||
|
await ValidateDiscountSchemesAsync(storeId, DateTime.Now, paid, added);
|
||||||
|
|
||||||
|
foreach (var id in added)
|
||||||
|
await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = id, FK_ShopPOId = shopPOId, DiscountAmount = 0m });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Discounts already redeemed against a Shop PO, for display and for editing.</summary>
|
||||||
|
public async Task<List<ShopPODiscountResponse>> GetDiscountsForShopPOAsync(Guid shopPOId) =>
|
||||||
|
(await repo.GetRedemptionsByShopPOAsync(shopPOId))
|
||||||
|
.Where(r => r.DiscountAmount.HasValue)
|
||||||
|
.Select(r => new ShopPODiscountResponse(r.FK_SchemeId, r.Scheme?.Name ?? string.Empty, r.DiscountAmount!.Value))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings the Shop PO's scheme redemptions in line with its saved lines: free-goods redemptions follow the
|
||||||
|
/// free lines (created, resized or removed), discount redemptions are recomputed or dropped if the order no
|
||||||
|
/// longer qualifies. Stages changes only — the caller owns the commit.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SyncRedemptionsAsync(Guid shopPOId, List<ShopPOItem> lines, List<Guid>? discountSchemeIds = null)
|
||||||
|
{
|
||||||
|
var existing = await repo.GetRedemptionsByShopPOAsync(shopPOId);
|
||||||
|
var freeBySchemes = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue)
|
||||||
|
.GroupBy(l => l.FK_SchemeId!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
foreach (var r in existing)
|
||||||
|
{
|
||||||
|
if (r.FK_FreeItemId.HasValue)
|
||||||
|
{
|
||||||
|
if (!freeBySchemes.ContainsKey(r.FK_SchemeId)) repo.RemoveRedemption(r);
|
||||||
|
}
|
||||||
|
else if (r.DiscountAmount.HasValue)
|
||||||
|
{
|
||||||
|
// A null list means "leave the accepted discounts alone" (just recompute them).
|
||||||
|
if (discountSchemeIds is not null && !discountSchemeIds.Contains(r.FK_SchemeId))
|
||||||
|
{
|
||||||
|
repo.RemoveRedemption(r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheme = await repo.GetByIdAsync(r.FK_SchemeId);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||||
|
if (eval is { Eligible: true, DiscountAmount: not null }) r.DiscountAmount = eval.DiscountAmount;
|
||||||
|
else repo.RemoveRedemption(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var schemeId in discountSchemeIds ?? [])
|
||||||
|
{
|
||||||
|
if (existing.Any(r => r.FK_SchemeId == schemeId && r.DiscountAmount.HasValue)) continue;
|
||||||
|
var scheme = await repo.GetByIdAsync(schemeId);
|
||||||
|
var eval = scheme is null ? null : EvaluateRule(scheme, lines);
|
||||||
|
if (eval is { Eligible: true, DiscountAmount: not null })
|
||||||
|
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||||
|
{
|
||||||
|
FK_SchemeId = schemeId,
|
||||||
|
FK_ShopPOId = shopPOId,
|
||||||
|
DiscountAmount = eval.DiscountAmount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (schemeId, group) in freeBySchemes)
|
||||||
|
{
|
||||||
|
var freeItem = await itemRepo.GetByIdAsync(group[0].FK_ItemId)
|
||||||
|
?? throw new InvalidOperationException("Free item not found.");
|
||||||
|
var qty = group.Sum(l => l.Quantity);
|
||||||
|
var redemption = existing.FirstOrDefault(r => r.FK_SchemeId == schemeId && r.FK_FreeItemId.HasValue);
|
||||||
|
|
||||||
|
if (redemption is null)
|
||||||
|
await repo.AddRedemptionAsync(new SchemeRedemption
|
||||||
|
{
|
||||||
|
FK_SchemeId = schemeId,
|
||||||
|
FK_ShopPOId = shopPOId,
|
||||||
|
FK_FreeItemId = freeItem.Id,
|
||||||
|
FreeQty = qty,
|
||||||
|
FreeItemCostValue = freeItem.CostPrice ?? 0m
|
||||||
|
});
|
||||||
|
else
|
||||||
|
{
|
||||||
|
redemption.FK_FreeItemId = freeItem.Id;
|
||||||
|
redemption.FreeQty = qty;
|
||||||
|
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
|
||||||
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
||||||
@@ -299,14 +620,18 @@ public class SchemeService(
|
|||||||
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||||
|
|
||||||
var today = DateTime.Now;
|
var today = DateTime.Now;
|
||||||
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
|
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
|
||||||
return (false, "Scheme is not currently active (outside its date range).");
|
return (false, "Scheme is not currently active (outside its date range).");
|
||||||
|
|
||||||
return (true, null);
|
return (true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
|
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> allItems)
|
||||||
{
|
{
|
||||||
|
// Free-issue lines (added by a previous redemption) must not count toward a trigger/bundle
|
||||||
|
// quantity, otherwise the free units inflate the multiplier.
|
||||||
|
var items = allItems.Where(i => !i.IsFree && i.UnitPrice > 0).ToList();
|
||||||
|
|
||||||
switch (scheme.RuleType)
|
switch (scheme.RuleType)
|
||||||
{
|
{
|
||||||
case SchemeRuleType.BuyXGetY:
|
case SchemeRuleType.BuyXGetY:
|
||||||
@@ -383,6 +708,9 @@ public class SchemeService(
|
|||||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||||
|
|
||||||
|
if (await repo.HasLockedInvoiceAsync(shopPOId))
|
||||||
|
throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed.");
|
||||||
|
|
||||||
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||||
var eligibility = EvaluateRule(scheme, items);
|
var eligibility = EvaluateRule(scheme, items);
|
||||||
if (!eligibility.Eligible)
|
if (!eligibility.Eligible)
|
||||||
@@ -411,7 +739,10 @@ public class SchemeService(
|
|||||||
FK_ShopPOId = shopPOId,
|
FK_ShopPOId = shopPOId,
|
||||||
FK_ItemId = freeItem.Id,
|
FK_ItemId = freeItem.Id,
|
||||||
Quantity = (int)eligibility.FreeQty.Value,
|
Quantity = (int)eligibility.FreeQty.Value,
|
||||||
UnitPrice = 0m
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = scheme.Id,
|
||||||
|
SchemeName = scheme.Name
|
||||||
});
|
});
|
||||||
|
|
||||||
redemption.FK_FreeItemId = freeItem.Id;
|
redemption.FK_FreeItemId = freeItem.Id;
|
||||||
@@ -422,7 +753,27 @@ public class SchemeService(
|
|||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
if (newItems.Count > 0)
|
||||||
|
{
|
||||||
|
await shopPORepo.AddItemsAsync(newItems);
|
||||||
|
|
||||||
|
// A still-draft invoice was copied from the PO before this line existed, so mirror it there.
|
||||||
|
if (shopPO.InvoiceId is Guid invoiceId &&
|
||||||
|
await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft })
|
||||||
|
{
|
||||||
|
await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem
|
||||||
|
{
|
||||||
|
FK_InvoiceId = invoiceId,
|
||||||
|
FK_ShopPOItemId = n.Id,
|
||||||
|
FK_ItemId = n.FK_ItemId,
|
||||||
|
Quantity = n.Quantity,
|
||||||
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = n.FK_SchemeId,
|
||||||
|
SchemeName = n.SchemeName
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
await repo.AddRedemptionAsync(redemption);
|
await repo.AddRedemptionAsync(redemption);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -484,6 +835,8 @@ public class SchemeService(
|
|||||||
|
|
||||||
if (freeItemId is null || freeQty is null or <= 0)
|
if (freeItemId is null || freeQty is null or <= 0)
|
||||||
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
||||||
|
if (freeQty.Value != decimal.Truncate(freeQty.Value))
|
||||||
|
throw new InvalidOperationException("FreeQty must be a whole number (order lines hold whole units).");
|
||||||
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
||||||
throw new InvalidOperationException("Free item not found.");
|
throw new InvalidOperationException("Free item not found.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("confirmed-without-invoice")]
|
||||||
|
public async Task<IActionResult> GetConfirmedWithoutInvoice(
|
||||||
|
[FromQuery] Guid? agentId,
|
||||||
|
[FromQuery] Guid? routeId,
|
||||||
|
[FromQuery] Guid? shopId)
|
||||||
|
{
|
||||||
|
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
namespace Distribution.Features.ShopPO;
|
namespace Distribution.Features.ShopPO;
|
||||||
|
|
||||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
|
||||||
|
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
|
||||||
|
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
|
||||||
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
||||||
|
|
||||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
|
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive,
|
||||||
|
bool IsFree = false, Guid? SchemeId = null, string? SchemeName = null);
|
||||||
|
|
||||||
|
public record ShopPODiscountResponse(Guid SchemeId, string SchemeName, decimal Amount);
|
||||||
|
|
||||||
public record ShopPOResponse(
|
public record ShopPOResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -34,4 +39,5 @@ public record ShopPOResponse(
|
|||||||
DateTime? CancelledAt = null,
|
DateTime? CancelledAt = null,
|
||||||
string? CancelReason = null,
|
string? CancelReason = null,
|
||||||
string? CancelledByName = null,
|
string? CancelledByName = null,
|
||||||
string? CancelledByRole = null);
|
string? CancelledByRole = null,
|
||||||
|
List<ShopPODiscountResponse>? Discounts = null);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Distribution.Features.Invoice;
|
using Distribution.Features.Invoice;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -10,16 +12,47 @@ public class ShopPOService(
|
|||||||
IShopPORepository repo,
|
IShopPORepository repo,
|
||||||
IInvoiceRepository invoiceRepo,
|
IInvoiceRepository invoiceRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
|
SchemeService schemeService,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
|
||||||
|
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
|
||||||
|
{
|
||||||
|
if (line.IsFree)
|
||||||
|
return new ShopPOItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitPrice = 0m,
|
||||||
|
IsFree = true,
|
||||||
|
FK_SchemeId = line.SchemeId
|
||||||
|
};
|
||||||
|
|
||||||
|
var unitPrice = line.UnitPrice
|
||||||
|
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
|
||||||
|
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||||
|
|
||||||
|
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
|
||||||
|
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
|
||||||
|
notifications.NotifyAsync(
|
||||||
|
type,
|
||||||
|
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
|
||||||
|
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
|
||||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||||
p.Items.Where(i => i.IsActive).Select(i =>
|
p.Items.Where(i => i.IsActive).Select(i =>
|
||||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
|
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
|
||||||
|
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
|
||||||
p.IsActive, p.CreatedAt,
|
p.IsActive, p.CreatedAt,
|
||||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
||||||
@@ -27,7 +60,8 @@ public class ShopPOService(
|
|||||||
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
||||||
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
||||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
|
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
|
||||||
|
discounts ?? []);
|
||||||
|
|
||||||
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||||
{
|
{
|
||||||
@@ -52,7 +86,14 @@ public class ShopPOService(
|
|||||||
var po = await repo.GetByIdAsync(id);
|
var po = await repo.GetByIdAsync(id);
|
||||||
if (po is null) return null;
|
if (po is null) return null;
|
||||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
var users = await repo.GetUserInfoAsync(UserIds(po));
|
||||||
return ToResponse(po, users);
|
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||||
|
{
|
||||||
|
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||||
|
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
||||||
|
return items.Select(p => ToResponse(p, users)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
||||||
@@ -65,20 +106,14 @@ public class ShopPOService(
|
|||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, request.StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
items.Add(item);
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
total += item.Quantity * item.UnitPrice;
|
||||||
|
|
||||||
items.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||||
|
|
||||||
var po = new Models.ShopPO
|
var po = new Models.ShopPO
|
||||||
{
|
{
|
||||||
FK_StoreId = request.StoreId,
|
FK_StoreId = request.StoreId,
|
||||||
@@ -93,6 +128,7 @@ public class ShopPOService(
|
|||||||
await repo.AddAsync(po);
|
await repo.AddAsync(po);
|
||||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||||
await repo.AddItemsAsync(items);
|
await repo.AddItemsAsync(items);
|
||||||
|
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
@@ -120,27 +156,22 @@ public class ShopPOService(
|
|||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
var unitPrice = line.UnitPrice
|
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
item.FK_ShopPOId = id;
|
||||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
newItems.Add(item);
|
||||||
|
total += item.Quantity * item.UnitPrice;
|
||||||
newItems.Add(new ShopPOItem
|
|
||||||
{
|
|
||||||
FK_ShopPOId = id,
|
|
||||||
FK_ItemId = line.ItemId,
|
|
||||||
Quantity = line.Quantity,
|
|
||||||
QuantityFulfilled = 0,
|
|
||||||
UnitPrice = unitPrice
|
|
||||||
});
|
|
||||||
total += line.Quantity * unitPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||||
|
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||||
|
|
||||||
po.TotalAmount = total;
|
po.TotalAmount = total;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await repo.ReplaceItemsAsync(id, newItems);
|
await repo.ReplaceItemsAsync(id, newItems);
|
||||||
|
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -168,6 +199,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,17 +226,25 @@ public class ShopPOService(
|
|||||||
CreatedBy = userId,
|
CreatedBy = userId,
|
||||||
TotalAmount = po.TotalAmount
|
TotalAmount = po.TotalAmount
|
||||||
};
|
};
|
||||||
|
// Start the invoice at the discounted total so it matches what print/finalize will recompute.
|
||||||
|
invoice.DiscountAmount = Math.Min(await invoiceRepo.GetSchemeDiscountAsync(po.Id), po.TotalAmount);
|
||||||
|
invoice.TotalAmount = po.TotalAmount - invoice.DiscountAmount;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await invoiceRepo.AddAsync(invoice);
|
await invoiceRepo.AddAsync(invoice);
|
||||||
|
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
|
||||||
|
await invoiceRepo.AddItemsAsync(lines);
|
||||||
|
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
|
||||||
po.InvoiceId = invoice.Id;
|
po.InvoiceId = invoice.Id;
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(id);
|
var saved = await repo.GetByIdAsync(id);
|
||||||
return await ToResponseWithUserAsync(saved!);
|
return await ToResponseWithUserAsync(saved!);
|
||||||
}
|
}
|
||||||
@@ -231,6 +272,8 @@ public class ShopPOService(
|
|||||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
|
||||||
|
|
||||||
return await ToResponseWithUserAsync(po);
|
return await ToResponseWithUserAsync(po);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Scheme;
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.StoreCredit;
|
using Distribution.Features.StoreCredit;
|
||||||
@@ -7,7 +8,7 @@ using Distribution.Models;
|
|||||||
|
|
||||||
namespace Distribution.Features.Store;
|
namespace Distribution.Features.Store;
|
||||||
|
|
||||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo)
|
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
|
||||||
{
|
{
|
||||||
private const int GlobalScopeRoleLevel = 50;
|
private const int GlobalScopeRoleLevel = 50;
|
||||||
private const int CreditApproverRoleLevel = 20;
|
private const int CreditApproverRoleLevel = 20;
|
||||||
@@ -148,6 +149,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
var store = await repo.GetByIdAsync(id);
|
var store = await repo.GetByIdAsync(id);
|
||||||
if (store is null || !store.IsActive) return null;
|
if (store is null || !store.IsActive) return null;
|
||||||
if (!await HasStoreScopeAsync(id)) return null;
|
if (!await HasStoreScopeAsync(id)) return null;
|
||||||
|
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
|
||||||
store.OutstandingBalance = request.NewBalance;
|
store.OutstandingBalance = request.NewBalance;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
@@ -160,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only on the crossing, so repeated updates while over the limit don't re-alert.
|
||||||
|
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.CreditLimitBreached,
|
||||||
|
[
|
||||||
|
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
|
||||||
|
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
|
||||||
|
],
|
||||||
|
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
|
||||||
|
ct);
|
||||||
|
|
||||||
return ToResponse(store);
|
return ToResponse(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -9,6 +10,7 @@ public class SupplierClaimService(
|
|||||||
ISupplierClaimRepository repo,
|
ISupplierClaimRepository repo,
|
||||||
IPoRepository poRepo,
|
IPoRepository poRepo,
|
||||||
IGrnRepository grnRepo,
|
IGrnRepository grnRepo,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||||
@@ -149,9 +151,19 @@ public class SupplierClaimService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
|
||||||
|
|
||||||
return Map(claim);
|
return Map(claim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tells whoever raised the claim how it was decided (never the person who decided it).
|
||||||
|
private Task NotifyDecisionAsync(string type, SupplierClaim claim, Guid decidedBy, CancellationToken ct) =>
|
||||||
|
notifications.NotifyAsync(
|
||||||
|
type,
|
||||||
|
[RecipientRule.Users(claim.RaisedByUserId)],
|
||||||
|
new NotificationArgs(null, "SupplierClaim", claim.Id, "/returns-claims/supplier-claims", decidedBy),
|
||||||
|
ct);
|
||||||
|
|
||||||
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var claim = await repo.GetByIdAsync(id)
|
var claim = await repo.GetByIdAsync(id)
|
||||||
@@ -172,6 +184,8 @@ public class SupplierClaimService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
|
||||||
|
|
||||||
return Map(claim);
|
return Map(claim);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Features.BankAccount;
|
using Distribution.Features.BankAccount;
|
||||||
using Distribution.Features.ChequeBook;
|
using Distribution.Features.ChequeBook;
|
||||||
using Distribution.Features.GeneralLedger;
|
|
||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
using Distribution.Features.Supplier;
|
using Distribution.Features.Supplier;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
@@ -18,8 +17,7 @@ public class SupplierPaymentService(
|
|||||||
IBankAccountRepository bankAccountRepo,
|
IBankAccountRepository bankAccountRepo,
|
||||||
IChequeBookRepository chequeBookRepo,
|
IChequeBookRepository chequeBookRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser)
|
||||||
GlPostingService gl)
|
|
||||||
{
|
{
|
||||||
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
||||||
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
||||||
@@ -163,13 +161,14 @@ public class SupplierPaymentService(
|
|||||||
|
|
||||||
await repo.AddAsync(payment);
|
await repo.AddAsync(payment);
|
||||||
|
|
||||||
|
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||||
|
// Debit Accounts Payable (supplier)
|
||||||
|
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
// Mirrors to the General Ledger after commit: Dr Payable / Cr Cash/Bank (or Cheques-issued). Never throws.
|
|
||||||
await gl.PostSupplierPaymentAsync(payment, ct);
|
|
||||||
|
|
||||||
return ToResponse(payment);
|
return ToResponse(payment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,12 +98,6 @@ namespace Distribution.Migrations
|
|||||||
table: "DeliveryTasks",
|
table: "DeliveryTasks",
|
||||||
newName: "FK_ShopPOId");
|
newName: "FK_ShopPOId");
|
||||||
|
|
||||||
// MariaDB drops the ascii collation on CHANGE COLUMN; restore it so the FK to ShopPOs.Id can be created.
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `DeliveryTasks` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `SchemeRedemptions` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NULL;");
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `SaleReturns` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
|
||||||
migrationBuilder.Sql("ALTER TABLE `Payments` MODIFY `FK_InvoiceId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
migrationBuilder.RenameIndex(
|
||||||
name: "IX_DeliveryTasks_FK_OrderId",
|
name: "IX_DeliveryTasks_FK_OrderId",
|
||||||
table: "DeliveryTasks",
|
table: "DeliveryTasks",
|
||||||
|
|||||||
+163
-211
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace Distribution.Migrations
|
namespace Distribution.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(AppDBContext))]
|
[DbContext(typeof(AppDBContext))]
|
||||||
[Migration("20261020082853_GlIntegration")]
|
[Migration("20261020075213_AddNotifications")]
|
||||||
partial class GlIntegration
|
partial class AddNotifications
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -59,9 +59,6 @@ namespace Distribution.Migrations
|
|||||||
.HasMaxLength(10)
|
.HasMaxLength(10)
|
||||||
.HasColumnType("varchar(10)");
|
.HasColumnType("varchar(10)");
|
||||||
|
|
||||||
b.Property<string>("GlAccountCode")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
@@ -631,57 +628,6 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.GlPostingLog", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<int>("Attempts")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<string>("Error")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<string>("Event")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(60)
|
|
||||||
.HasColumnType("varchar(60)");
|
|
||||||
|
|
||||||
b.Property<string>("GlJournalNo")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("tinyint(1)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastAttemptAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<string>("PayloadJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<string>("Reference")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<Guid>("SourceId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Event", "SourceId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("GlPostingLogs");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1335,6 +1281,93 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("LoadingSheetOrders");
|
b.ToTable("LoadingSheetOrders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("EntityType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Link")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("Severity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("varchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("varchar(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EntityType", "EntityId");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_NotificationId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_UserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReadAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_NotificationId", "FK_UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("FK_UserId", "IsRead");
|
||||||
|
|
||||||
|
b.ToTable("NotificationRecipients");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1868,22 +1901,6 @@ namespace Distribution.Migrations
|
|||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Description = "Update system configuration settings",
|
Description = "Update system configuration settings",
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("20000000-0000-0000-0000-000000000051"),
|
|
||||||
Code = "finance-reports.view",
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)",
|
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("20000000-0000-0000-0000-000000000052"),
|
|
||||||
Code = "gl.manage",
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Description = "Set up the General Ledger link and retry failed postings",
|
|
||||||
IsActive = true
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2601,7 +2618,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("52dedbb8-6251-4131-82de-41c2064a663b"),
|
Id = new Guid("e748cecd-7b03-41ba-96f4-e7e80d8b39f5"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2609,7 +2626,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("47450af3-cf95-4725-a208-eeca124022b7"),
|
Id = new Guid("6ec338c9-dbe1-40b0-895c-228bcb439815"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2617,7 +2634,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("44deed45-fc6f-420c-a026-f4483efab782"),
|
Id = new Guid("eb390164-72ff-4b03-8d09-184f25fe2e0b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2625,7 +2642,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"),
|
Id = new Guid("e8b5c2b0-cdba-42f0-b1f0-32a892036c0a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2633,7 +2650,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"),
|
Id = new Guid("48340fe7-3037-4ab1-8800-59763bce0855"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2641,7 +2658,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"),
|
Id = new Guid("d43d5a8b-4a0a-4a7a-9b34-7c0561ed9303"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2649,7 +2666,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("16ad892c-b785-4872-a426-1015f32c90b9"),
|
Id = new Guid("41c7bd2c-0253-4315-8594-811aec35f15c"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2657,7 +2674,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"),
|
Id = new Guid("87052d54-7319-473e-a46f-62d6477ef7d8"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2665,7 +2682,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("498230b5-7d98-4826-afaf-944123658f69"),
|
Id = new Guid("c0b0185e-3530-4cc8-9e6b-b51cd1187dda"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2673,7 +2690,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"),
|
Id = new Guid("6e649c16-703b-4420-ae23-8840d1dc247b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2681,7 +2698,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"),
|
Id = new Guid("efe29c4c-353a-4b25-9279-6566c8ca1b3d"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2689,7 +2706,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"),
|
Id = new Guid("3aa9f6cd-0075-4ab2-88c4-c49e177d443a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2697,7 +2714,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"),
|
Id = new Guid("258dfadf-292e-410c-a173-217983becc2b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2705,7 +2722,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"),
|
Id = new Guid("c253907f-f84e-4ea5-af4a-0db56b89b82a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2713,7 +2730,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"),
|
Id = new Guid("c47972c0-6a5f-47cf-ae30-cbf753a6fe94"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2721,7 +2738,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"),
|
Id = new Guid("ee4b11f3-78d4-4e6f-bb22-95f3f18be7e5"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2729,7 +2746,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("86229744-f68d-4e7e-9b07-2aad58584331"),
|
Id = new Guid("a0a39f4e-95c9-42bc-9803-a510fa4863d7"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2737,7 +2754,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"),
|
Id = new Guid("29e55c7f-92cb-47fd-b05c-ce038284ff39"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2745,7 +2762,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"),
|
Id = new Guid("7b675ce2-3949-4e1a-ad6e-e1a047d84ece"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2753,7 +2770,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"),
|
Id = new Guid("835c1000-3998-444c-b9bf-53b2ef810437"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2761,7 +2778,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("48772797-f181-423d-bfb3-f6226a9abe11"),
|
Id = new Guid("3c9d0a5d-059e-4e0b-b1a8-698ce2a076ae"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2769,7 +2786,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"),
|
Id = new Guid("f12fc85a-5a91-4e23-81de-0b497d4bc246"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2777,7 +2794,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"),
|
Id = new Guid("08005497-616c-4279-aa8a-162762d57a88"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2785,7 +2802,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"),
|
Id = new Guid("666a9e41-1c11-4af3-8f72-c80ba3734a48"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2793,7 +2810,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"),
|
Id = new Guid("87cd58d6-68f2-4de0-a0d8-5951a2281184"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2801,7 +2818,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"),
|
Id = new Guid("a7a0cb93-598b-4e21-9bab-ae5ba4da71d3"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2809,7 +2826,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"),
|
Id = new Guid("86bf0611-4798-4209-a5e4-a0064a5e95cd"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2817,7 +2834,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("8f63c426-38bb-456d-adff-75e477857607"),
|
Id = new Guid("bd386f32-0f7a-4954-9e85-15956b4fb718"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2825,7 +2842,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"),
|
Id = new Guid("9dd219a4-da77-49f5-8ea1-097dbba231ea"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2833,7 +2850,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"),
|
Id = new Guid("5caf3706-bde2-43bf-b9a8-d351500e6cba"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2841,7 +2858,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"),
|
Id = new Guid("63ba1d73-9819-4471-a89b-1eacade54d84"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2849,7 +2866,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("12e98296-456e-40a3-b11c-61d3393b335e"),
|
Id = new Guid("58398d5c-4687-4a7f-a205-d6a3ff365c6f"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2857,7 +2874,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"),
|
Id = new Guid("72761e2e-cd4f-44d1-b446-d5ab974374d4"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2865,7 +2882,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("5e701ad5-f462-4642-86d1-303148323a32"),
|
Id = new Guid("66c1a66f-5d2d-4532-8f47-441a9a9df5c3"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2873,7 +2890,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"),
|
Id = new Guid("6e37280f-b1cc-4aba-a8df-2f42ee1958a0"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2881,7 +2898,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"),
|
Id = new Guid("dd4149c0-8746-4ceb-92c2-27cec640fca2"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2889,7 +2906,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"),
|
Id = new Guid("e51495d1-bb65-4345-8156-73c791589d1f"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2897,7 +2914,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"),
|
Id = new Guid("ccf5af23-acd7-4bdb-a2ea-ec409a970add"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2905,7 +2922,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"),
|
Id = new Guid("11e001a5-7a47-4778-996b-0f2aa1e4ae73"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2913,7 +2930,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"),
|
Id = new Guid("6b1edf33-51c0-4cae-ba2f-9737e0c91e42"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2921,7 +2938,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("a2515783-f099-4332-a07c-be81f2e85160"),
|
Id = new Guid("0a54915c-1660-4657-96fb-e55d01e8a31a"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2929,7 +2946,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"),
|
Id = new Guid("646a6638-8520-4fd8-8608-aa7e29bb874d"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2937,7 +2954,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"),
|
Id = new Guid("8fe75d07-1c6c-4019-becc-d5e82f2cb777"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2945,7 +2962,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"),
|
Id = new Guid("05732dc6-a967-481b-b055-76ff68c5728e"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2953,7 +2970,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"),
|
Id = new Guid("864da17e-470f-41f5-a053-07f112c090ea"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2961,7 +2978,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"),
|
Id = new Guid("f8c31d84-f57f-4515-86f2-733442d9ea14"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2969,7 +2986,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"),
|
Id = new Guid("ba7e7d95-51f7-4ebb-b911-9bc18a104a39"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2977,7 +2994,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"),
|
Id = new Guid("b4cfa315-1b8a-4f61-af60-5389391f973c"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2985,7 +3002,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"),
|
Id = new Guid("a03e5eff-1925-4ddf-904b-32be0daed97b"),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -2993,23 +3010,7 @@ namespace Distribution.Migrations
|
|||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Id = new Guid("93c036e4-d811-4915-b73c-1cb358f17065"),
|
Id = new Guid("be5ff487-33d9-4c63-81f4-fd7f6553c5bf"),
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000051"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("9c27d325-1915-41c6-9b62-d85a12f102e1"),
|
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
|
|
||||||
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000052"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("1f48dd7b-2f14-451d-b80d-71579d5625e6"),
|
|
||||||
IsActive = true
|
IsActive = true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3994,54 +3995,6 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.StoreCreditMovement", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
|
||||||
.HasPrecision(18, 2)
|
|
||||||
.HasColumnType("decimal(18,2)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<int>("Direction")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<Guid?>("FK_InvoiceId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<Guid?>("FK_PaymentId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<Guid>("FK_StoreId")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("tinyint(1)");
|
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<DateTime>("OccurredAt")
|
|
||||||
.HasColumnType("datetime(6)");
|
|
||||||
|
|
||||||
b.Property<Guid?>("RecordedBy")
|
|
||||||
.HasColumnType("char(36)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("FK_InvoiceId");
|
|
||||||
|
|
||||||
b.HasIndex("FK_PaymentId");
|
|
||||||
|
|
||||||
b.HasIndex("FK_StoreId", "OccurredAt");
|
|
||||||
|
|
||||||
b.ToTable("StoreCreditMovements");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -5104,6 +5057,25 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("ShopPO");
|
b.Navigation("ShopPO");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Notification", "Notification")
|
||||||
|
.WithMany("Recipients")
|
||||||
|
.HasForeignKey("FK_NotificationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Notification");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
|
||||||
@@ -5536,31 +5508,6 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Route");
|
b.Navigation("Route");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.StoreCreditMovement", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Distribution.Models.Invoice", "Invoice")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("FK_InvoiceId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Payment", "Payment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("FK_PaymentId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Store", "Store")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("FK_StoreId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Invoice");
|
|
||||||
|
|
||||||
b.Navigation("Payment");
|
|
||||||
|
|
||||||
b.Navigation("Store");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Category", "Category")
|
b.HasOne("Distribution.Models.Category", "Category")
|
||||||
@@ -5821,6 +5768,11 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Orders");
|
b.Navigation("Orders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Recipients");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNotifications : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Type = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Severity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Message = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
EntityType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
EntityId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Link = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notifications", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NotificationRecipients",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_NotificationId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_UserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
ReadAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NotificationRecipients", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_NotificationRecipients_Notifications_FK_NotificationId",
|
||||||
|
column: x => x.FK_NotificationId,
|
||||||
|
principalTable: "Notifications",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_NotificationRecipients_Users_FK_UserId",
|
||||||
|
column: x => x.FK_UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NotificationRecipients_FK_NotificationId_FK_UserId",
|
||||||
|
table: "NotificationRecipients",
|
||||||
|
columns: new[] { "FK_NotificationId", "FK_UserId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NotificationRecipients_FK_UserId_IsRead",
|
||||||
|
table: "NotificationRecipients",
|
||||||
|
columns: new[] { "FK_UserId", "IsRead" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_EntityType_EntityId",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "EntityType", "EntityId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NotificationRecipients");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notifications");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoiceItems : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InvoiceItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InvoiceItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceItems_Invoices_FK_InvoiceId",
|
||||||
|
column: x => x.FK_InvoiceId,
|
||||||
|
principalTable: "Invoices",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceItems_ShopPOItems_FK_ShopPOItemId",
|
||||||
|
column: x => x.FK_ShopPOItemId,
|
||||||
|
principalTable: "ShopPOItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceItems_FK_InvoiceId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
column: "FK_InvoiceId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceItems_FK_ItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceItems_FK_ShopPOItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
column: "FK_ShopPOItemId");
|
||||||
|
|
||||||
|
// Backfill: snapshot each existing invoice's PO lines so older invoices can be edited/finalized.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO InvoiceItems (Id, FK_InvoiceId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, CreatedAt, IsActive)
|
||||||
|
SELECT UUID(), inv.Id, poi.Id, poi.FK_ItemId, poi.Quantity, poi.UnitPrice, NOW(6), 1
|
||||||
|
FROM Invoices inv
|
||||||
|
JOIN ShopPOItems poi ON poi.FK_ShopPOId = inv.FK_ShopPOId AND poi.IsActive = 1;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InvoiceItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6080
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoiceVersionsAndLineEdits : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "FK_ShopPOItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci",
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "char(36)")
|
||||||
|
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsRemoved",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "tinyint(1)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InvoiceVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
VersionNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Stage = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InvoiceVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersions_Invoices_FK_InvoiceId",
|
||||||
|
column: x => x.FK_InvoiceId,
|
||||||
|
principalTable: "Invoices",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InvoiceVersionItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_InvoiceVersionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
IsRemoved = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InvoiceVersionItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersionItems_InvoiceVersions_FK_InvoiceVersionId",
|
||||||
|
column: x => x.FK_InvoiceVersionId,
|
||||||
|
principalTable: "InvoiceVersions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersionItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InvoiceVersionItems_ShopPOItems_FK_ShopPOItemId",
|
||||||
|
column: x => x.FK_ShopPOItemId,
|
||||||
|
principalTable: "ShopPOItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersionItems_FK_InvoiceVersionId",
|
||||||
|
table: "InvoiceVersionItems",
|
||||||
|
column: "FK_InvoiceVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersionItems_FK_ItemId",
|
||||||
|
table: "InvoiceVersionItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersionItems_FK_ShopPOItemId",
|
||||||
|
table: "InvoiceVersionItems",
|
||||||
|
column: "FK_ShopPOItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InvoiceVersions_FK_InvoiceId_VersionNumber",
|
||||||
|
table: "InvoiceVersions",
|
||||||
|
columns: new[] { "FK_InvoiceId", "VersionNumber" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
// Backfill: one baseline version per existing invoice from its current lines.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO InvoiceVersions (Id, FK_InvoiceId, VersionNumber, Stage, TotalAmount, CreatedBy, CreatedAt, IsActive)
|
||||||
|
SELECT UUID(), inv.Id, 1,
|
||||||
|
CASE inv.Status WHEN 0 THEN 0 WHEN 1 THEN 2 ELSE 3 END,
|
||||||
|
inv.TotalAmount, inv.CreatedBy, NOW(6), 1
|
||||||
|
FROM Invoices inv;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO InvoiceVersionItems (Id, FK_InvoiceVersionId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, IsRemoved, CreatedAt, IsActive)
|
||||||
|
SELECT UUID(), v.Id, ii.FK_ShopPOItemId, ii.FK_ItemId, ii.Quantity, ii.UnitPrice, ii.IsRemoved, NOW(6), 1
|
||||||
|
FROM InvoiceVersions v
|
||||||
|
JOIN InvoiceItems ii ON ii.FK_InvoiceId = v.FK_InvoiceId AND ii.IsActive = 1
|
||||||
|
WHERE v.VersionNumber = 1;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InvoiceVersionItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InvoiceVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsRemoved",
|
||||||
|
table: "InvoiceItems");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "FK_ShopPOItemId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
collation: "ascii_general_ci",
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "char(36)",
|
||||||
|
oldNullable: true)
|
||||||
|
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+416
-89
@@ -8,60 +8,236 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||||||
namespace Distribution.Migrations
|
namespace Distribution.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class GlIntegration : Migration
|
public partial class AddExpenseModule : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "GlAccountCode",
|
|
||||||
table: "BankAccounts",
|
|
||||||
type: "longtext",
|
|
||||||
nullable: true)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4");
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "GlPostingLogs",
|
name: "ExpenseCategories",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
Event = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
|
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
|
||||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
SourceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
Code = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true)
|
||||||
Reference = table.Column<string>(type: "longtext", nullable: false)
|
|
||||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
Status = table.Column<int>(type: "int", nullable: false),
|
FK_ParentCategoryId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
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),
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_GlPostingLogs", x => x.Id);
|
table.PrimaryKey("PK_ExpenseCategories", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseCategories_ExpenseCategories_FK_ParentCategoryId",
|
||||||
|
column: x => x.FK_ParentCategoryId,
|
||||||
|
principalTable: "ExpenseCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
})
|
})
|
||||||
.Annotation("MySql:CharSet", "utf8mb4");
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.CreateTable(
|
||||||
table: "BankAccounts",
|
name: "ExpenseVendors",
|
||||||
keyColumn: "Id",
|
columns: table => new
|
||||||
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
{
|
||||||
column: "GlAccountCode",
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
value: null);
|
Name = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
ContactInfo = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExpenseVendors", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RecurringExpenseTemplates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Name = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
FK_CategoryId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_WarehouseId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
FK_VendorId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
|
||||||
|
Payee = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Description = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Frequency = table.Column<int>(type: "int", nullable: false),
|
||||||
|
DayOfMonth = table.Column<int>(type: "int", nullable: true),
|
||||||
|
DayOfWeek = table.Column<int>(type: "int", nullable: true),
|
||||||
|
StartDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
EndDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
NextRunDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
AutoPost = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
IsPaused = 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_RecurringExpenseTemplates", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RecurringExpenseTemplates_ExpenseCategories_FK_CategoryId",
|
||||||
|
column: x => x.FK_CategoryId,
|
||||||
|
principalTable: "ExpenseCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RecurringExpenseTemplates_ExpenseVendors_FK_VendorId",
|
||||||
|
column: x => x.FK_VendorId,
|
||||||
|
principalTable: "ExpenseVendors",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RecurringExpenseTemplates_Warehouses_FK_WarehouseId",
|
||||||
|
column: x => x.FK_WarehouseId,
|
||||||
|
principalTable: "Warehouses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Expenses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_CategoryId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_WarehouseId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
|
||||||
|
BillDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
DueDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
PeriodCovered = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
FK_VendorId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
Payee = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
BillRef = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Description = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Method = table.Column<int>(type: "int", nullable: true),
|
||||||
|
FK_BankAccountId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
FK_ChequePageId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
PaidAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
RecordedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
PostedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
PostedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
PaidByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
VoidedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
VoidedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
VoidReason = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
FK_RecurringTemplateId = 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_Expenses", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_BankAccounts_FK_BankAccountId",
|
||||||
|
column: x => x.FK_BankAccountId,
|
||||||
|
principalTable: "BankAccounts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_ChequePages_FK_ChequePageId",
|
||||||
|
column: x => x.FK_ChequePageId,
|
||||||
|
principalTable: "ChequePages",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_ExpenseCategories_FK_CategoryId",
|
||||||
|
column: x => x.FK_CategoryId,
|
||||||
|
principalTable: "ExpenseCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_ExpenseVendors_FK_VendorId",
|
||||||
|
column: x => x.FK_VendorId,
|
||||||
|
principalTable: "ExpenseVendors",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_RecurringExpenseTemplates_FK_RecurringTemplateId",
|
||||||
|
column: x => x.FK_RecurringTemplateId,
|
||||||
|
principalTable: "RecurringExpenseTemplates",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_Warehouses_FK_WarehouseId",
|
||||||
|
column: x => x.FK_WarehouseId,
|
||||||
|
principalTable: "Warehouses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExpenseAttachments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ExpenseId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
OriginalFileName = table.Column<string>(type: "varchar(260)", maxLength: 260, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
StoredPath = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
ContentType = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
UploadedByUserId = 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_ExpenseAttachments", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseAttachments_Expenses_FK_ExpenseId",
|
||||||
|
column: x => x.FK_ExpenseId,
|
||||||
|
principalTable: "Expenses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ExpenseCategories",
|
||||||
|
columns: new[] { "Id", "Code", "CreatedAt", "FK_ParentCategoryId", "IsActive", "Name" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000001"), "SAL", new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Salaries" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000002"), "UTL", new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Utilities" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000003"), "RNT", new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Rent" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000004"), "VEH", new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Vehicle" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000005"), "OFF", new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Office" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000006"), "BNK", new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Bank Fees" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000007"), "MSC", new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Miscellaneous" }
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
migrationBuilder.InsertData(
|
||||||
table: "Permissions",
|
table: "Permissions",
|
||||||
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
|
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
|
||||||
values: new object[,]
|
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-000000000051"), "expense.view", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "View expenses, categories, vendors and recurring templates", 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 }
|
{ new Guid("20000000-0000-0000-0000-000000000052"), "expense.create", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Create and edit draft expenses", true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000053"), "expense.post", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Post a draft expense (approve it)", true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000054"), "expense.pay", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Pay a posted expense (debit a bank account or issue a cheque)", true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000055"), "expense.void", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Void an expense", true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000056"), "expense.manage_categories", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Manage expense categories, titles and vendors", true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000057"), "expense.manage_recurring", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Manage recurring expense templates", true }
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
@@ -69,372 +245,477 @@ namespace Distribution.Migrations
|
|||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("52dedbb8-6251-4131-82de-41c2064a663b"));
|
value: new Guid("fa88ee94-4387-45a8-8e0b-67a3eb0d9537"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("47450af3-cf95-4725-a208-eeca124022b7"));
|
value: new Guid("007f96a6-dbb2-4745-be0f-7566ebab4f72"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("44deed45-fc6f-420c-a026-f4483efab782"));
|
value: new Guid("855bb33c-4507-435f-86e7-b86128d61099"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"));
|
value: new Guid("86b89230-02bb-42d8-90d4-f93a75d729f8"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"));
|
value: new Guid("2dcd53da-35e2-44c5-9fd9-84e5eeecb099"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"));
|
value: new Guid("d32844a6-5e5d-4ac8-a56c-4bad70be7ceb"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("16ad892c-b785-4872-a426-1015f32c90b9"));
|
value: new Guid("1a49ce67-81e2-4c6f-8e16-e07824964fc8"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"));
|
value: new Guid("f4c6729b-5347-4be2-8686-db7347958f35"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("498230b5-7d98-4826-afaf-944123658f69"));
|
value: new Guid("d35f14b7-34ca-4637-8a22-9ca8e3ac52ad"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"));
|
value: new Guid("fcbb69ec-6023-4fb3-8d41-30658666015d"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"));
|
value: new Guid("88c5c40c-d211-40d9-8bf5-df11e9995738"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"));
|
value: new Guid("0d5c388f-08f1-4ee5-adcd-5e0d8e061128"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"));
|
value: new Guid("7e726a5d-f487-4e29-8730-75fd64dc0100"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"));
|
value: new Guid("9f6e9256-cb78-4a44-863e-6d31e69ff8d5"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"));
|
value: new Guid("8fbdda8b-9eb3-490c-8c21-7a8f51c82404"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"));
|
value: new Guid("56f26354-9548-42c2-8b58-30c936db1bcd"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("86229744-f68d-4e7e-9b07-2aad58584331"));
|
value: new Guid("69d9de8f-107b-42d0-b345-e215b5cd5ccc"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"));
|
value: new Guid("21fa8dd9-596d-4c2c-86de-0a27e4e8cfd4"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"));
|
value: new Guid("750380a2-57da-4b98-b26f-2d893055e83f"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"));
|
value: new Guid("9a628e80-953f-4b55-8f46-aafb51a2f03a"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("48772797-f181-423d-bfb3-f6226a9abe11"));
|
value: new Guid("82e0dcfc-9df0-4135-b1bc-29bafca0e203"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"));
|
value: new Guid("40b78431-d5fc-4ff3-b12c-cfd9560b24e4"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"));
|
value: new Guid("9b3dfd17-cedd-4d7c-940c-5123f3a41b04"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"));
|
value: new Guid("7230884d-f645-4aac-974e-9b4f8b46b9ff"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"));
|
value: new Guid("9e688eea-f12f-4e19-aefb-1e19e781dee5"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"));
|
value: new Guid("9d6babbb-3775-4780-ab60-740d6d51f228"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"));
|
value: new Guid("7386b804-bc20-4c7f-9a83-7de92a554f3d"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("8f63c426-38bb-456d-adff-75e477857607"));
|
value: new Guid("28228445-b041-46a1-b44f-c6b782bdb306"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"));
|
value: new Guid("e8f3a603-3493-41ef-b1fa-a0747c93903e"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"));
|
value: new Guid("8157e3d4-f167-47ce-a94c-a13de1ecbbea"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"));
|
value: new Guid("9c587621-e2a4-48b6-863e-7b95ba0d4615"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("12e98296-456e-40a3-b11c-61d3393b335e"));
|
value: new Guid("b5c889df-6dee-49fc-809f-396711d8eebb"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"));
|
value: new Guid("3a892eaf-8fd4-4db0-ada0-619af8e6da11"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("5e701ad5-f462-4642-86d1-303148323a32"));
|
value: new Guid("c1504432-86de-4bdc-bf61-47bbb5e40f19"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"));
|
value: new Guid("ea52442b-2106-4ef0-9d99-73ef24620f8d"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"));
|
value: new Guid("e5d0003f-4d2e-4d49-9085-7141477d7536"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"));
|
value: new Guid("795c1b6a-d2f7-4be1-9ba4-cb9c9015bac1"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"));
|
value: new Guid("2c9af086-aec2-43f8-8bd6-bcd03e2acb31"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"));
|
value: new Guid("f2b6da6b-c0ca-4674-878b-13609840cf9a"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"));
|
value: new Guid("7580af1a-9570-4817-b2da-d0fdd25e6402"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("a2515783-f099-4332-a07c-be81f2e85160"));
|
value: new Guid("1f6e4e53-7821-4c2a-a749-5be09b39e26d"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"));
|
value: new Guid("a41c5218-e402-47d1-a0a6-67c0fa0228a8"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"));
|
value: new Guid("98ef584e-58fb-462b-9cdf-439228aa3be4"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"));
|
value: new Guid("a4a06976-e48c-4cb4-9933-e9c222f6c80d"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"));
|
value: new Guid("068494b3-4cc7-4b70-b771-a73035f26ca4"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"));
|
value: new Guid("0e54ed62-3450-49af-9c57-b247e5b9a813"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"));
|
value: new Guid("80f35776-d931-435c-a02c-eed567593b5b"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"));
|
value: new Guid("69e6f0fb-75ef-41ee-a229-40ceef5bbf3e"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"));
|
value: new Guid("be136f8c-f847-4881-a723-60cf2cd874e4"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||||
column: "Id",
|
column: "Id",
|
||||||
value: new Guid("93c036e4-d811-4915-b73c-1cb358f17065"));
|
value: new Guid("b3abf6c8-5317-4688-a5c4-426e843fb93c"));
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ExpenseCategories",
|
||||||
|
columns: new[] { "Id", "Code", "CreatedAt", "FK_ParentCategoryId", "IsActive", "Name" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000101"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000001"), true, "Basic Salary" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000102"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000001"), true, "Bonus" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000103"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000001"), true, "Overtime" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000201"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000002"), true, "Electricity" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000202"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000002"), true, "Water" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000203"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000002"), true, "Internet" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000301"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000003"), true, "Warehouse Rent" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000302"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000003"), true, "Office Rent" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000401"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000004"), true, "Fuel" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000402"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000004"), true, "Maintenance" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000403"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000004"), true, "Insurance" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000501"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000005"), true, "Stationery" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000502"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000005"), true, "Cleaning" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000601"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000006"), true, "Service Charges" },
|
||||||
|
{ new Guid("30000000-0000-0000-0000-000000000701"), null, new DateTime(2026, 10, 21, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000007"), true, "Other" }
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
migrationBuilder.InsertData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
|
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
|
||||||
values: new object[,]
|
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-000000000051"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("756c4a39-7754-4b97-b967-29e7745084f6"), 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 }
|
{ 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("0dee3b9c-f54e-4373-a33c-f774ab005787"), true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000053"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("0132bd0a-4baa-47fb-b2e2-e056dd662b0f"), true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000054"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("6cfbcfa8-c46e-4d57-8481-b6c695a6d2d8"), true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000055"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("04bdabea-6f18-4090-b21a-98afb14cc1b9"), true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000056"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("ae57197c-20f8-40be-9f3b-6a614c02403c"), true },
|
||||||
|
{ new Guid("20000000-0000-0000-0000-000000000057"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("b27b94e1-1779-40ad-b212-53298735b0f2"), true }
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_GlPostingLogs_Event_SourceId",
|
name: "IX_ExpenseAttachments_FK_ExpenseId",
|
||||||
table: "GlPostingLogs",
|
table: "ExpenseAttachments",
|
||||||
columns: new[] { "Event", "SourceId" },
|
column: "FK_ExpenseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseCategories_FK_ParentCategoryId_Name",
|
||||||
|
table: "ExpenseCategories",
|
||||||
|
columns: new[] { "FK_ParentCategoryId", "Name" },
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_FK_BankAccountId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "FK_BankAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_FK_CategoryId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "FK_CategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_FK_ChequePageId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "FK_ChequePageId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_FK_RecurringTemplateId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "FK_RecurringTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_FK_VendorId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "FK_VendorId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_FK_WarehouseId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "FK_WarehouseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_Status_BillDate",
|
||||||
|
table: "Expenses",
|
||||||
|
columns: new[] { "Status", "BillDate" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseVendors_Name",
|
||||||
|
table: "ExpenseVendors",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecurringExpenseTemplates_FK_CategoryId",
|
||||||
|
table: "RecurringExpenseTemplates",
|
||||||
|
column: "FK_CategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecurringExpenseTemplates_FK_VendorId",
|
||||||
|
table: "RecurringExpenseTemplates",
|
||||||
|
column: "FK_VendorId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecurringExpenseTemplates_FK_WarehouseId",
|
||||||
|
table: "RecurringExpenseTemplates",
|
||||||
|
column: "FK_WarehouseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecurringExpenseTemplates_IsPaused_NextRunDate",
|
||||||
|
table: "RecurringExpenseTemplates",
|
||||||
|
columns: new[] { "IsPaused", "NextRunDate" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "GlPostingLogs");
|
name: "ExpenseAttachments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Expenses");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RecurringExpenseTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseVendors");
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
migrationBuilder.DeleteData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
@@ -446,6 +727,31 @@ namespace Distribution.Migrations
|
|||||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000052"), new Guid("10000000-0000-0000-0000-000000000007") });
|
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000052"), 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-000000000053"), 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-000000000054"), 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-000000000055"), 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-000000000056"), 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-000000000057"), new Guid("10000000-0000-0000-0000-000000000007") });
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
migrationBuilder.DeleteData(
|
||||||
table: "Permissions",
|
table: "Permissions",
|
||||||
keyColumn: "Id",
|
keyColumn: "Id",
|
||||||
@@ -456,9 +762,30 @@ namespace Distribution.Migrations
|
|||||||
keyColumn: "Id",
|
keyColumn: "Id",
|
||||||
keyValue: new Guid("20000000-0000-0000-0000-000000000052"));
|
keyValue: new Guid("20000000-0000-0000-0000-000000000052"));
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DeleteData(
|
||||||
name: "GlAccountCode",
|
table: "Permissions",
|
||||||
table: "BankAccounts");
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("20000000-0000-0000-0000-000000000053"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Permissions",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("20000000-0000-0000-0000-000000000054"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Permissions",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("20000000-0000-0000-0000-000000000055"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Permissions",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("20000000-0000-0000-0000-000000000056"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Permissions",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("20000000-0000-0000-0000-000000000057"));
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
migrationBuilder.UpdateData(
|
||||||
table: "RolePermissions",
|
table: "RolePermissions",
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoiceDiscount : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "DiscountAmount",
|
||||||
|
table: "Invoices",
|
||||||
|
type: "decimal(65,30)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Invoices",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("34500000-0000-0000-0000-000000000001"),
|
||||||
|
column: "DiscountAmount",
|
||||||
|
value: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Invoices",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("34500000-0000-0000-0000-000000000002"),
|
||||||
|
column: "DiscountAmount",
|
||||||
|
value: 0m);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DiscountAmount",
|
||||||
|
table: "Invoices");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFreeIssueLines : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "FK_SchemeId",
|
||||||
|
table: "ShopPOItems",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsFree",
|
||||||
|
table: "ShopPOItems",
|
||||||
|
type: "tinyint(1)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SchemeName",
|
||||||
|
table: "ShopPOItems",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "FK_SchemeId",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsFree",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "tinyint(1)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SchemeName",
|
||||||
|
table: "InvoiceItems",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
// Existing free-issue lines were plain zero-priced lines; flag them from their scheme redemption.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE ShopPOItems i
|
||||||
|
JOIN SchemeRedemptions r ON r.FK_ShopPOId = i.FK_ShopPOId AND r.FK_FreeItemId = i.FK_ItemId
|
||||||
|
JOIN Schemes s ON s.Id = r.FK_SchemeId
|
||||||
|
SET i.IsFree = 1, i.FK_SchemeId = r.FK_SchemeId, i.SchemeName = s.Name
|
||||||
|
WHERE i.UnitPrice = 0 AND i.IsActive = 1;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE InvoiceItems ii
|
||||||
|
JOIN ShopPOItems pi ON ii.FK_ShopPOItemId = pi.Id
|
||||||
|
SET ii.IsFree = pi.IsFree, ii.FK_SchemeId = pi.FK_SchemeId, ii.SchemeName = pi.SchemeName
|
||||||
|
WHERE pi.IsFree = 1;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FK_SchemeId",
|
||||||
|
table: "ShopPOItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsFree",
|
||||||
|
table: "ShopPOItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SchemeName",
|
||||||
|
table: "ShopPOItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FK_SchemeId",
|
||||||
|
table: "InvoiceItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsFree",
|
||||||
|
table: "InvoiceItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SchemeName",
|
||||||
|
table: "InvoiceItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,5 @@ namespace Distribution.Models
|
|||||||
public string CurrencyCode { get; set; } = "LKR";
|
public string CurrencyCode { get; set; } = "LKR";
|
||||||
|
|
||||||
public bool IsDefault { get; set; }
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
/// <summary>Code of the mirrored General Ledger account (child of GL Cash in Hand / Bank Accounts). Null until synced.</summary>
|
|
||||||
public string? GlAccountCode { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace Distribution.Models
|
|||||||
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
||||||
|
|
||||||
public enum InvoiceStatus { Draft, Printed, Finalized }
|
public enum InvoiceStatus { Draft, Printed, Finalized }
|
||||||
|
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
|
||||||
|
|
||||||
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||||
|
|
||||||
@@ -67,4 +68,10 @@ namespace Distribution.Models
|
|||||||
public enum ReceivedChequeStatusAction { Deposit, Clear, Return, Cancel }
|
public enum ReceivedChequeStatusAction { Deposit, Clear, Return, Cancel }
|
||||||
|
|
||||||
public enum ReceivedFromType { Customer, Supplier, Other }
|
public enum ReceivedFromType { Customer, Supplier, Other }
|
||||||
|
|
||||||
|
public enum ExpenseStatus { Draft, Posted, Paid, Void }
|
||||||
|
|
||||||
|
public enum ExpenseAction { Draft, Post, Pay }
|
||||||
|
|
||||||
|
public enum RecurringFrequency { Weekly, Monthly, Quarterly, Yearly }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class Expense : Template
|
||||||
|
{
|
||||||
|
/// <summary>Leaf category (Title). Parent is the user-facing Category group.</summary>
|
||||||
|
public Guid FK_CategoryId { get; set; }
|
||||||
|
[ForeignKey("FK_CategoryId")]
|
||||||
|
public ExpenseCategory? Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Branch the expense is attributed to. Null = company-wide.</summary>
|
||||||
|
public Guid? FK_WarehouseId { get; set; }
|
||||||
|
[ForeignKey("FK_WarehouseId")]
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
[Precision(18, 2)]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
public DateTime BillDate { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
/// <summary>First day of the month the expense covers (YYYY-MM-01).</summary>
|
||||||
|
public DateTime? PeriodCovered { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_VendorId { get; set; }
|
||||||
|
[ForeignKey("FK_VendorId")]
|
||||||
|
public ExpenseVendor? Vendor { get; set; }
|
||||||
|
|
||||||
|
public string? Payee { get; set; }
|
||||||
|
public string? BillRef { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
|
||||||
|
|
||||||
|
/// <summary>Payment settlement — null until Status transitions to Paid.</summary>
|
||||||
|
public PaymentMethod? Method { get; set; }
|
||||||
|
public Guid? FK_BankAccountId { get; set; }
|
||||||
|
[ForeignKey("FK_BankAccountId")]
|
||||||
|
public BankAccount? BankAccount { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_ChequePageId { get; set; }
|
||||||
|
[ForeignKey("FK_ChequePageId")]
|
||||||
|
public ChequePage? ChequePage { get; set; }
|
||||||
|
|
||||||
|
public DateTime? PaidAt { get; set; }
|
||||||
|
|
||||||
|
public Guid RecordedByUserId { get; set; }
|
||||||
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
public DateTime? PostedAt { get; set; }
|
||||||
|
public Guid? PaidByUserId { get; set; }
|
||||||
|
public Guid? VoidedByUserId { get; set; }
|
||||||
|
public DateTime? VoidedAt { get; set; }
|
||||||
|
public string? VoidReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null for manually-entered expenses; set when generated by run-due.</summary>
|
||||||
|
public Guid? FK_RecurringTemplateId { get; set; }
|
||||||
|
[ForeignKey("FK_RecurringTemplateId")]
|
||||||
|
public RecurringExpenseTemplate? RecurringTemplate { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ExpenseAttachment> Attachments { get; set; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class ExpenseAttachment : Template
|
||||||
|
{
|
||||||
|
public Guid FK_ExpenseId { get; set; }
|
||||||
|
[ForeignKey("FK_ExpenseId")]
|
||||||
|
public Expense? Expense { get; set; }
|
||||||
|
|
||||||
|
public string OriginalFileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Absolute path on disk. Served through a streaming endpoint, never statically.</summary>
|
||||||
|
public string StoredPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ContentType { get; set; } = string.Empty;
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
|
||||||
|
public Guid UploadedByUserId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Two-level tree: top-level categories (e.g. "Salaries", "Utilities") and
|
||||||
|
/// child "titles" (e.g. "Bonus", "Electricity"). Expenses link to a leaf (Title).
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseCategory : Template
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Code { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_ParentCategoryId { get; set; }
|
||||||
|
[ForeignKey("FK_ParentCategoryId")]
|
||||||
|
public ExpenseCategory? Parent { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight vendor directory used only by the Expenses module. Kept
|
||||||
|
/// separate from Supplier (which has performance scores, PO/GRN linkage).
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseVendor : Template
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? ContactInfo { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
namespace Distribution.Models
|
|
||||||
{
|
|
||||||
public enum GlPostingStatus { Pending, Posted, Failed }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One row per journal entry Distribution sends to the General Ledger service. (Event, SourceId) is unique,
|
|
||||||
/// so a business event can never be posted twice; Failed rows keep their payload and can be retried.
|
|
||||||
/// </summary>
|
|
||||||
public class GlPostingLog : Template
|
|
||||||
{
|
|
||||||
public string Event { get; set; } = string.Empty;
|
|
||||||
public Guid SourceId { get; set; }
|
|
||||||
public string Reference { get; set; } = string.Empty;
|
|
||||||
public GlPostingStatus Status { get; set; } = GlPostingStatus.Pending;
|
|
||||||
|
|
||||||
/// <summary>The journal entry body (without periodId, which is resolved from EntryDate at send time).</summary>
|
|
||||||
public string PayloadJson { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? GlJournalNo { get; set; }
|
|
||||||
public string? Error { get; set; }
|
|
||||||
public int Attempts { get; set; }
|
|
||||||
public DateTime? LastAttemptAt { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,13 @@ namespace Distribution.Models
|
|||||||
public DateTime? FinalizedAt { get; set; }
|
public DateTime? FinalizedAt { get; set; }
|
||||||
public Guid? FinalizedBy { get; set; }
|
public Guid? FinalizedBy { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Scheme discount redeemed against the PO, subtracted from the billable lines to give TotalAmount.</summary>
|
||||||
|
public decimal DiscountAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Payable amount: billable lines minus DiscountAmount.</summary>
|
||||||
public decimal TotalAmount { get; set; }
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
|
||||||
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class InvoiceItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_InvoiceId { get; set; }
|
||||||
|
[ForeignKey("FK_InvoiceId")]
|
||||||
|
public Invoice Invoice { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_ShopPOItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ShopPOItemId")]
|
||||||
|
public ShopPOItem? ShopPOItem { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemId")]
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public bool IsRemoved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free issue copied from the Shop PO line (UnitPrice 0).</summary>
|
||||||
|
public bool IsFree { get; set; }
|
||||||
|
public Guid? FK_SchemeId { get; set; }
|
||||||
|
public string? SchemeName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
// Immutable snapshot of an invoice's lines at a point in its lifecycle.
|
||||||
|
public class InvoiceVersion : Template
|
||||||
|
{
|
||||||
|
public Guid FK_InvoiceId { get; set; }
|
||||||
|
[ForeignKey("FK_InvoiceId")]
|
||||||
|
public Invoice Invoice { get; set; }
|
||||||
|
|
||||||
|
public int VersionNumber { get; set; }
|
||||||
|
public InvoiceVersionStage Stage { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public Guid CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public ICollection<InvoiceVersionItem> Items { get; set; } = new List<InvoiceVersionItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class InvoiceVersionItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_InvoiceVersionId { get; set; }
|
||||||
|
[ForeignKey("FK_InvoiceVersionId")]
|
||||||
|
public InvoiceVersion InvoiceVersion { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_ShopPOItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ShopPOItemId")]
|
||||||
|
public ShopPOItem? ShopPOItem { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemId")]
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public bool IsRemoved { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user