price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
Submodule .claude/worktrees/agent-a98805dc618e0f67c deleted from b4993b202d
@@ -0,0 +1,211 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>Recording and cancelling bank deposits (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2, §11).</summary>
public class BankDepositServiceTests
{
private static readonly DateOnly Today = Clock.Today;
private static readonly DateOnly Start = Today.AddDays(-10);
private static readonly DateOnly Day = Today.AddDays(-2);
[Fact]
public async Task Create_PostsBankDebitCashCredit_AndLowersCashInHand()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
var gl = new FakeGeneralLedger();
var dto = await DepositService(db, Start, gl).CreateAsync(Request(600, depositDate: Day.AddDays(1)));
Assert.Equal(BankDepositStatus.Posted, dto.Status);
Assert.Equal(("BOC Current", "Bank of Ceylon", "0001234567"), (dto.ToBankAccountName, dto.ToBankName, dto.ToAccountNumber));
Assert.Equal("JE-1", dto.GlJournalNo);
var entry = Assert.Single(gl.Posted);
Assert.Equal(Day.AddDays(1), entry.EntryDate);
Assert.Equal("BANK_DEPOSIT", entry.SourceModule);
Assert.Collection(entry.Lines,
l => Assert.Equal(("1010-01", 600m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("1000-01", 0m, 600m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
var position = await CashService(db, Start).GetPositionAsync(Day, Branch1);
Assert.Equal((600m, 400m), (position.Position.Deposited, position.Position.Closing));
}
[Fact]
public async Task Create_MoreThanCashInHand_IsRejected()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000, card: 5000));
await db.SaveChangesAsync();
var gl = new FakeGeneralLedger();
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, gl).CreateAsync(Request(1000.01m)));
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
Assert.Empty(gl.Posted);
}
[Fact]
public async Task Create_BackDatedDepositThatOverdrawsALaterDay_IsRejected()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
db.BankDeposits.Add(Deposit(Day.AddDays(1), 900)); // the next day already banked most of it
await db.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<DomainException>(() =>
DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(500)));
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
Assert.Contains("100.00", ex.Message);
}
[Fact]
public async Task Cancel_ReversesGl_AndPutsTheCashBack()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
var gl = new FakeGeneralLedger();
var service = DepositService(db, Start, gl);
var created = await service.CreateAsync(Request(1000));
var cancelled = await service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = " wrong bank " });
Assert.Equal(BankDepositStatus.Cancelled, cancelled.Status);
Assert.Equal("wrong bank", cancelled.CancelReason);
Assert.Equal("JE-2", cancelled.GlReversalJournalNo);
Assert.Collection(gl.Posted[1].Lines,
l => Assert.Equal(("1000-01", 1000m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("1010-01", 0m, 1000m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
Assert.Equal(1000m, (await CashService(db, Start).GetPositionAsync(Day, Branch1)).Position.Closing);
var again = await Assert.ThrowsAsync<DomainException>(() =>
service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = "again" }));
Assert.Equal(ErrorCodes.BankDepositNotCancellable, again.Code);
}
[Theory]
[InlineData(FakeGeneralLedger.BankAccountId, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
[InlineData(FakeGeneralLedger.CashAccountId, FakeGeneralLedger.CashAccountId, ErrorCodes.BankDepositBankAccountInvalid)]
[InlineData(99L, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
public async Task Create_WrongAccountTypes_AreRejected(long from, long to, string code)
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
var request = Request(100);
request.FromGlCashAccountId = from;
request.ToGlBankAccountId = to;
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
Assert.Equal(code, ex.Code);
}
[Fact]
public async Task Create_CashAndBankAccountsSharingAnId_AreToldApartByType()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
// GL numbers cash and bank accounts in separate tables, so both can be account 1.
var gl = new FakeGeneralLedger
{
Accounts =
[
new("Cash", 1, "main", null, "Cash in Transit", "CASH-000001", 22, "1400-01-01", "LKR"),
new("Bank", 1, "Main - 01", "Commercial Bank", null, "2032434543", 2, "1000-01", "LKR")
]
};
var request = Request(400);
request.FromGlCashAccountId = 1;
request.ToGlBankAccountId = 1;
var dto = await DepositService(db, Start, gl).CreateAsync(request);
Assert.Equal(("main", "Main - 01"), (dto.FromCashAccountName, dto.ToBankAccountName));
Assert.Collection(Assert.Single(gl.Posted).Lines,
l => Assert.Equal(("1000-01", 400m), (l.AccountCode, l.DebitAmount)),
l => Assert.Equal(("1400-01-01", 400m), (l.AccountCode, l.CreditAmount)));
}
[Fact]
public async Task Create_CannotUseAnotherBranchsCash()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000, warehouseId: Warehouse2));
await db.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<DomainException>(() =>
DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(100, branchId: Branch1)));
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
}
[Theory]
[InlineData(-11, null)] // before tracking starts
[InlineData(1, null)] // business date in the future
[InlineData(-2, -3)] // deposited before the business day
[InlineData(-2, 2)] // deposit date beyond tomorrow
public async Task Create_InvalidDates_AreRejected(int businessOffset, int? depositOffset)
{
await using var db = CreateContext();
var request = Request(100);
request.BusinessDate = Today.AddDays(businessOffset);
request.DepositDate = depositOffset is { } d ? Today.AddDays(d) : null;
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
Assert.Equal(ErrorCodes.BankDepositDateInvalid, ex.Code);
}
[Fact]
public async Task Create_GlRejection_LeavesNoDeposit()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
await Assert.ThrowsAsync<DomainException>(() =>
DepositService(db, Start, new FakeGeneralLedger { Reject = true }).CreateAsync(Request(500)));
Assert.Equal(0, await db.BankDeposits.CountAsync());
}
[Fact]
public async Task List_FiltersAndTotalsEveryMatchingRow()
{
await using var db = CreateContext();
db.BankDeposits.AddRange(
Deposit(Day, 100), Deposit(Day, 200), Deposit(Day.AddDays(-1), 400),
Deposit(Day, 800, branchId: Branch2), Deposit(Day, 1600, status: BankDepositStatus.Cancelled));
await db.SaveChangesAsync();
var result = await DepositService(db, Start, new FakeGeneralLedger()).ListAsync(
new() { PageSize = 1 }, new BankDepositListFilter(Day, Day, Branch1, null, BankDepositStatus.Posted));
Assert.Single(result.Items);
Assert.Equal(2, result.Pagination.TotalItems);
Assert.Equal(300m, result.TotalAmount);
}
private static CreateBankDepositRequest Request(decimal amount, int branchId = Branch1, DateOnly? depositDate = null) => new()
{
BranchId = branchId,
BusinessDate = Day,
DepositDate = depositDate,
Amount = amount,
FromGlCashAccountId = FakeGeneralLedger.CashAccountId,
ToGlBankAccountId = FakeGeneralLedger.BankAccountId,
DepositSlipNo = "SLIP-1"
};
}
@@ -0,0 +1,134 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>Till cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §11).</summary>
public class CashInHandServiceTests
{
private static readonly DateOnly Today = Clock.Today;
private static readonly DateOnly Start = Today.AddDays(-10);
[Fact]
public async Task OnlyCashIsCounted()
{
await using var db = CreateContext();
var day = Today.AddDays(-2);
db.SalesSlips.AddRange(
CashSlip(day, cash: 300, card: 200), // only the 300 is till cash
CashSlip(day, cash: 0, card: 900));
db.SalesInvoices.Add(new SalesInvoice
{
SalesInvoiceId = 1, InvoiceNo = "INV-1", InvoiceDate = At(day.AddDays(-5)), Status = SalesInvoiceStatus.Posted, BranchId = Branch1
});
db.SalesInvoicePayments.AddRange(
new SalesInvoicePayment { SalesInvoicePaymentId = 1, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cash, Amount = 150 },
new SalesInvoicePayment { SalesInvoicePaymentId = 2, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Card, Amount = 500 },
new SalesInvoicePayment { SalesInvoicePaymentId = 3, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cheque, Amount = 70 });
db.Expenses.Add(new Expense { ExpenseId = 1, DocNo = "EXP-1", BranchId = Branch1, ExpenseDate = day, Amount = 100, Status = ExpenseStatus.Posted });
db.ExpensePayments.AddRange(
new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.Cash, Amount = 40 },
new ExpensePayment { ExpensePaymentId = 2, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.BankTransfer, Amount = 60 });
await db.SaveChangesAsync();
var p = (await CashService(db, Start).GetPositionAsync(day, Branch1)).Position;
Assert.Equal(450m, p.CashIn);
Assert.Equal(40m, p.CashOut);
Assert.Equal(410m, p.NetCash);
Assert.Equal(410m, p.Closing);
}
[Fact]
public async Task UndepositedCash_CarriesToTheNextDay_AndDepositsReduceIt()
{
await using var db = CreateContext();
var d1 = Today.AddDays(-3);
var d2 = Today.AddDays(-2);
db.SalesSlips.AddRange(CashSlip(d1, 1000), CashSlip(d2, 500));
db.BankDeposits.AddRange(
Deposit(d1, 600),
Deposit(d1, 999, status: BankDepositStatus.Cancelled)); // ignored
await db.SaveChangesAsync();
var days = (await CashService(db, Start).GetDailyAsync(d1, d2, Branch1)).Days;
Assert.Equal((0m, 1000m, 600m, 400m), (days[0].Opening, days[0].NetCash, days[0].Deposited, days[0].Closing));
Assert.Equal((400m, 500m, 0m, 900m), (days[1].Opening, days[1].NetCash, days[1].Deposited, days[1].Closing));
}
[Fact]
public async Task Opening_IsCarriedInFromBeforeTheWindow()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(CashSlip(Today.AddDays(-6), 700), CashSlip(Today.AddDays(-5), 300));
db.BankDeposits.Add(Deposit(Today.AddDays(-5), 200));
await db.SaveChangesAsync();
var day = (await CashService(db, Start).GetPositionAsync(Today.AddDays(-1), Branch1)).Position;
Assert.Equal(800m, day.Opening);
Assert.Equal(800m, day.Closing);
}
[Fact]
public async Task CashBeforeTheTrackingStart_IsNotCarried()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(CashSlip(Start.AddDays(-1), 5000), CashSlip(Start, 100));
await db.SaveChangesAsync();
var days = (await CashService(db, Start).GetDailyAsync(Start.AddDays(-1), Start, Branch1)).Days;
Assert.Equal(5000m, days[0].NetCash); // still reported for the day itself
Assert.Equal(0m, days[1].Opening); // but not carried into tracking
Assert.Equal(100m, days[1].Closing);
}
[Fact]
public async Task Available_IsTheLowestClosingFromThatDayToToday()
{
await using var db = CreateContext();
var d1 = Today.AddDays(-2);
db.SalesSlips.Add(CashSlip(d1, 1000));
db.BankDeposits.Add(Deposit(Today.AddDays(-1), 800)); // already took most of it the next day
await db.SaveChangesAsync();
var position = await CashService(db, Start).GetPositionAsync(d1, Branch1);
Assert.Equal(1000m, position.Position.Closing);
Assert.Equal(200m, position.AvailableToDeposit);
}
[Fact]
public async Task Branches_AreKeptApart()
{
await using var db = CreateContext();
var day = Today.AddDays(-1);
db.SalesSlips.AddRange(CashSlip(day, 100, warehouseId: Warehouse1), CashSlip(day, 900, warehouseId: Warehouse2));
await db.SaveChangesAsync();
var service = CashService(db, Start);
Assert.Equal(100m, (await service.GetPositionAsync(day, Branch1)).Position.Closing);
Assert.Equal(900m, (await service.GetPositionAsync(day, Branch2)).Position.Closing);
Assert.Equal(1000m, (await service.GetPositionAsync(day, null)).Position.Closing);
}
[Fact]
public async Task LateEveningSale_CountsOnTheLocalDay()
{
await using var db = CreateContext();
var day = Today.AddDays(-1);
var slip = CashSlip(day, 250);
slip.SlipDate = Clock.ToUtc(day.AddDays(1)).AddMinutes(-30); // 23:30 local
db.SalesSlips.Add(slip);
await db.SaveChangesAsync();
var days = (await CashService(db, Start).GetDailyAsync(day, day.AddDays(1), Branch1)).Days;
Assert.Equal(250m, days[0].CashIn);
Assert.Equal(0m, days[1].CashIn);
}
}
@@ -0,0 +1,257 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Tests;
/// <summary>Aggregates of the Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §7).</summary>
public class DayEndReportServiceTests
{
private static readonly InMemoryDatabaseRoot Root = new();
// Asia/Colombo is UTC+05:30 all year (no DST), so local midnight is 18:30 UTC the day before.
private static readonly DateOnly Day = new(2026, 10, 8);
private static DateTime UtcAt(int hour, int minute = 0) => new(2026, 10, 8, hour, minute, 0, DateTimeKind.Utc);
[Fact]
public async Task Day_MixOfSlipsBundlesAndInvoices_TotalsAndSplitsByMethod()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(
Slip(1, UtcAt(5), total: 1000, paid: 600, cash: 400, card: 200), // partial, mixed
Slip(2, UtcAt(6), total: 200, paid: 200, cash: 200, card: 0), // all cash
Slip(3, UtcAt(7), total: 999, paid: 999, cash: 999, card: 0, status: SalesSlipStatus.Draft));
db.BundleSales.Add(new BundleSale { BundleSaleId = 1, BundleDate = UtcAt(8), Status = BundleSaleStatus.Posted, GrandTotal = 500, CashAmount = 0, CardAmount = 500 });
db.SalesInvoices.Add(Invoice(1, UtcAt(9), netPayable: 2000, paid: 1500));
db.SalesInvoicePayments.AddRange(
Payment(1, invoiceId: 1, UtcAt(9), SalesPaymentMethod.Cash, 500),
Payment(2, invoiceId: 1, UtcAt(10), SalesPaymentMethod.Card, 1000));
SeedExpenseSetup(db);
db.Expenses.AddRange(
Expense(1, Day, 300, ExpenseStatus.Posted),
Expense(2, Day, 700, ExpenseStatus.Cancelled),
Expense(3, Day.AddDays(1), 50, ExpenseStatus.Posted));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal(3700m, s.TotalSales);
Assert.Equal(2800m, s.PaidAmount);
Assert.Equal(900m, s.DueAmount);
Assert.Equal(1100m, s.PaidByCash);
Assert.Equal(1700m, s.PaidByCard);
Assert.Equal(0m, s.PaidByOther);
Assert.Equal(4, s.SalesCount);
Assert.Equal(1, s.CashSalesCount);
Assert.Equal(1, s.CardSalesCount);
Assert.Equal(2, s.MixedSalesCount);
Assert.Equal(0, s.UnpaidSalesCount);
Assert.Equal(300m, s.ExpenseTotal);
Assert.Equal(3400m, s.Income);
Assert.Single(report.Expenses);
Assert.Equal("Electricity", report.Expenses[0].TitleName);
}
[Fact]
public async Task Day_SaleLateInTheLocalEvening_CountsOnTheLocalDay()
{
await using var db = CreateContext();
// 19:00 UTC on Oct 7 is 00:30 on Oct 8 in Colombo; 18:00 UTC on Oct 8 is 23:30 on Oct 8.
db.SalesSlips.AddRange(
Slip(1, new DateTime(2026, 10, 7, 19, 0, 0, DateTimeKind.Utc), total: 100, paid: 100, cash: 100, card: 0),
Slip(2, new DateTime(2026, 10, 8, 18, 0, 0, DateTimeKind.Utc), total: 40, paid: 40, cash: 40, card: 0),
Slip(3, new DateTime(2026, 10, 8, 19, 0, 0, DateTimeKind.Utc), total: 7, paid: 7, cash: 7, card: 0));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day.AddDays(1), null);
Assert.Equal([0m, 140m, 7m], report.Days.Select(d => d.TotalSales));
Assert.Equal(147m, report.Totals.TotalSales);
}
[Fact]
public async Task Day_PaymentOnAnEarlierInvoice_IsACollectionAndCountsInNetCash()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5).AddDays(-3), netPayable: 800, paid: 250));
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(6), SalesPaymentMethod.Cash, 250));
db.SalesSlips.Add(Slip(1, UtcAt(7), total: 60, paid: 60, cash: 60, card: 0));
SeedExpenseSetup(db);
db.Expenses.Add(Expense(1, Day, 30, ExpenseStatus.Posted));
db.ExpensePayments.Add(new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, Amount = 30, PaymentDate = UtcAt(8), PaymentMethod = ExpensePaymentMethod.Cash });
await db.SaveChangesAsync();
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
Assert.Equal(60m, s.TotalSales);
Assert.Equal(60m, s.PaidAmount);
Assert.Equal(250m, s.EarlierInvoiceCollections);
Assert.Equal(60m + 250m - 30m, s.NetCash);
}
[Fact]
public async Task Day_ShowsBankDepositsAndTheCashLeftInHand()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(
Slip(1, UtcAt(5).AddDays(-1), total: 300, paid: 300, cash: 300, card: 0), // yesterday, never banked
Slip(2, UtcAt(6), total: 1000, paid: 1000, cash: 1000, card: 0));
db.BankDeposits.AddRange(
BankDeposit(1, Day, 800, BankDepositStatus.Posted),
BankDeposit(2, Day, 5000, BankDepositStatus.Cancelled));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal((300m, 1000m, 800m, 500m), (s.OpeningCashInHand, s.NetCash, s.CashDeposited, s.ClosingCashInHand));
var deposit = Assert.Single(report.Deposits);
Assert.Equal(("BD-1", "BOC Current", 800m), (deposit.DocNo, deposit.BankAccountName, deposit.Amount));
var range = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day, null);
Assert.Equal((0m, 800m, 500m), (range.Totals.OpeningCashInHand, range.Totals.CashDeposited, range.Totals.ClosingCashInHand));
}
[Fact]
public async Task Day_Profit_IsSalesLessStockCostLessExpenses_AndCanBeALoss()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 1000, paid: 1000));
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(5), SalesPaymentMethod.Card, 1000));
db.SalesSlips.Add(Slip(1, UtcAt(6), total: 200, paid: 0, cash: 0, card: 0));
db.StockLedger.AddRange(
Ledger(1, DocumentTypes.SalesInvoice, 1, Direction.Out, 600),
Ledger(2, DocumentTypes.SalesInvoice, 1, Direction.Out, 100),
Ledger(3, DocumentTypes.SalesSlip, 1, Direction.Out, 150),
Ledger(4, DocumentTypes.SalesInvoice, 1, Direction.In, 999)); // not a sale's cost
SeedExpenseSetup(db);
db.Expenses.Add(Expense(1, Day, 900, ExpenseStatus.Posted));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal(850m, s.CostOfGoodsSold);
Assert.Equal(350m, s.GrossProfit);
Assert.Equal(-550m, s.NetProfit);
Assert.Equal(2, report.Sales.Count);
var invoice = report.Sales.Single(x => x.DocType == "Invoice");
Assert.Equal(("Card", 700m, 300m), (invoice.PaidBy, invoice.Cost, invoice.GrossProfit));
Assert.Equal("Unpaid", report.Sales.Single(x => x.DocType == "Slip").PaidBy);
}
[Fact]
public async Task Day_UnpaidInvoice_CountsAsUnpaidAndDue()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 450, paid: 0));
await db.SaveChangesAsync();
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
Assert.Equal(1, s.UnpaidSalesCount);
Assert.Equal(450m, s.DueAmount);
}
[Fact]
public async Task Daily_FillsQuietDaysWithZeros()
{
await using var db = CreateContext();
var report = await CreateService(db).GetDailyAsync(Day, Day.AddDays(4), null);
Assert.Equal(5, report.Days.Count);
Assert.All(report.Days, d => Assert.Equal(0m, d.Income));
Assert.Equal(Day.AddDays(4), report.Days[^1].Date);
}
[Theory]
[InlineData(1, 0)] // to before from
[InlineData(0, 92)] // 93 days
public async Task Daily_InvalidRange_IsRejected(int fromOffset, int toOffset)
{
await using var db = CreateContext();
var ex = await Assert.ThrowsAsync<DomainException>(() =>
CreateService(db).GetDailyAsync(Day.AddDays(fromOffset), Day.AddDays(toOffset), null));
Assert.Equal(ErrorCodes.DayEndReportRangeInvalid, ex.Code);
}
// --- helpers ------------------------------------------------------------------------------
private static SalesSlip Slip(int id, DateTime at, decimal total, decimal paid, decimal cash, decimal card,
SalesSlipStatus status = SalesSlipStatus.Posted) => new()
{
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = at, Status = status,
GrandTotal = total, PaidAmount = paid, BalanceAmount = total - paid, CashAmount = cash, CardAmount = card
};
private static SalesInvoice Invoice(int id, DateTime at, decimal netPayable, decimal paid) => new()
{
SalesInvoiceId = id, InvoiceNo = $"INV-{id}", InvoiceDate = at, Status = SalesInvoiceStatus.Posted,
GrandTotal = netPayable, NetPayable = netPayable, PaidAmount = paid, BalanceAmount = netPayable - paid
};
private static SalesInvoicePayment Payment(int id, int invoiceId, DateTime at, SalesPaymentMethod method, decimal amount) => new()
{
SalesInvoicePaymentId = id, SalesInvoiceId = invoiceId, PaymentDate = at, PaymentMethod = method, Amount = amount
};
private static StockLedger Ledger(int id, string docType, int docId, Direction direction, decimal value) => new()
{
LedgerId = id, SourceDocType = docType, SourceDocId = docId, Direction = direction, Value = value, QtyBase = 1, ItemId = 1, WarehouseId = 1
};
private static Expense Expense(int id, DateOnly date, decimal amount, ExpenseStatus status) => new()
{
ExpenseId = id, DocNo = $"EXP-{id}", ExpenseDate = date, Amount = amount, Status = status,
ExpenseCategoryId = 1, ExpenseTitleId = 1
};
private static BankDeposit BankDeposit(int id, DateOnly businessDate, decimal amount, BankDepositStatus status) => new()
{
BankDepositId = id, DocNo = $"BD-{id}", BranchId = 1, BusinessDate = businessDate, DepositDate = businessDate, Amount = amount,
Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current"
};
private static void SeedExpenseSetup(ErpDbContext db)
{
db.ExpenseCategories.Add(new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility" });
db.ExpenseTitles.Add(new ExpenseTitle { ExpenseTitleId = 1, ExpenseCategoryId = 1, Name = "Electricity", GlExpenseAccountCode = "5200-01" });
}
private static DayEndReportService CreateService(ErpDbContext db)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["DayEndReport:TimeZone"] = "Asia/Colombo" })
.Build();
var clock = new BusinessDayClock(config);
var branch = new CurrentBranchContext(new TestCurrentUser());
var cash = new CashInHandService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db), branch, clock, config);
return new DayEndReportService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoice>(db),
new Repository<SalesInvoicePayment>(db), new Repository<StockLedger>(db),
new Repository<Expense>(db), new Repository<BankDeposit>(db), cash, branch, clock);
}
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
}
+388
View File
@@ -0,0 +1,388 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.Products;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Tests;
/// <summary>Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §13).</summary>
public class DualApprovalTests
{
private static readonly InMemoryDatabaseRoot Root = new();
// Requester, two approvers, and a clerk who can approve but isn't a manager.
private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
private const int ApproverRoleId = 2, ClerkRoleId = 3;
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1;
// --- Stock adjustments ------------------------------------------------------------------
[Fact]
public async Task StockAdjustment_IsARequest_NothingMovesUntilApproved()
{
var h = await Harness.CreateAsync();
var dto = await h.Stock.CreateAsync(Request(-3));
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
Assert.Equal(2, dto.RequiredApprovals);
Assert.Equal(1, dto.PendingLevel);
Assert.Empty(h.Mutator.Calls);
Assert.Empty(h.Gl.Posted);
var pending = Assert.Single(h.Notifications.Targeted, n => n.ModuleKey == "approval.pending");
Assert.Equal([ApproverA, ApproverB, Clerk], pending.UserIds.Order()); // never the requester
}
[Fact]
public async Task StockAdjustment_TwoDifferentApprovers_ThenItPosts()
{
var h = await Harness.CreateAsync();
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
h.User.Be(Requester);
var own = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalForbidden, own.Code);
h.User.Be(ApproverA);
var first = await h.Stock.ApproveAsync(id, "counted twice");
Assert.Equal(AdjustmentStatus.PendingSecondApproval, first.Status);
Assert.Empty(h.Mutator.Calls);
var twice = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalSamePerson, twice.Code);
h.User.Be(ApproverB);
var posted = await h.Stock.ApproveAsync(id, null);
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
Assert.Equal((ApproverA, ApproverB), (posted.ApprovedLevel1By, posted.ApprovedLevel2By));
var call = Assert.Single(h.Mutator.Calls);
Assert.Equal(-3m, Assert.Single(call).QtyDelta);
Assert.Single(h.Gl.Posted);
Assert.NotNull(posted.PostedAt);
Assert.Equal([1, 2], posted.ApprovalHistory!.Select(s => s.Level));
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.approved" && n.UserIds.SequenceEqual([Requester]));
}
[Fact]
public async Task StockAdjustment_Rejected_NeedsAReason_AndNeverPosts()
{
var h = await Harness.CreateAsync();
var id = (await h.Stock.CreateAsync(Request(5))).AdjustmentId;
h.User.Be(ApproverA);
var noReason = await Assert.ThrowsAsync<DomainException>(() => h.Stock.RejectAsync(id, " "));
Assert.Equal(ErrorCodes.ApprovalReasonRequired, noReason.Code);
var rejected = await h.Stock.RejectAsync(id, "wrong warehouse");
Assert.Equal((AdjustmentStatus.Rejected, "wrong warehouse"), (rejected.Status, rejected.RejectReason));
h.User.Be(ApproverB);
var late = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalNotPending, late.Code);
Assert.Empty(h.Mutator.Calls);
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.rejected");
}
[Fact]
public async Task StockAdjustment_ApprovalsOff_PostsOnCreate()
{
var h = await Harness.CreateAsync(stockLevels: 0);
var dto = await h.Stock.CreateAsync(Request(2));
Assert.Equal((AdjustmentStatus.Posted, 0), (dto.Status, dto.RequiredApprovals));
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task StockAdjustment_BelowTheValueThreshold_NeedsOneApproval()
{
// 3 units at 10.00 each = 30.00, below the 1,000 threshold.
var h = await Harness.CreateAsync(stockThreshold: 1000);
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
h.User.Be(ApproverA);
var posted = await h.Stock.ApproveAsync(id, null);
Assert.Equal((AdjustmentStatus.Posted, 1), (posted.Status, posted.RequiredApprovals));
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task StockAdjustment_OnlyTheRequesterCanWithdraw()
{
var h = await Harness.CreateAsync();
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
h.User.Be(ApproverA);
var other = await Assert.ThrowsAsync<DomainException>(() => h.Stock.CancelAsync(id));
Assert.Equal(ErrorCodes.ApprovalForbidden, other.Code);
h.User.Be(Requester);
Assert.Equal(AdjustmentStatus.Cancelled, (await h.Stock.CancelAsync(id)).Status);
}
[Fact]
public async Task SecondApproval_CanBeLimitedToRoles()
{
var h = await Harness.CreateAsync(level2Roles: "Approver");
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
h.User.Be(ApproverA);
await h.Stock.ApproveAsync(id, null);
h.User.Be(Clerk);
var clerk = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalRoleRequired, clerk.Code);
h.User.Be(ApproverB);
Assert.Equal(AdjustmentStatus.Posted, (await h.Stock.ApproveAsync(id, null)).Status);
}
// --- Price adjustments ------------------------------------------------------------------
[Fact]
public async Task PriceAdjustment_AppliesOnlyAfterTheSecondApproval_AndKeepsHistory()
{
var h = await Harness.CreateAsync();
var dto = await h.Prices.CreateAsync(PriceRequest(flat: 120m));
Assert.Equal(PriceAdjustmentStatus.PendingApproval, dto.Status);
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
h.User.Be(ApproverA);
await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
h.User.Be(ApproverB);
var applied = await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
Assert.Equal(PriceAdjustmentStatus.Approved, applied.Status);
Assert.Equal(120m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
var history = Assert.Single(await h.Prices.ItemHistoryAsync(ItemId));
Assert.Equal((100m, 120m, ApproverA, ApproverB, dto.DocNo), (history.OldFlatPrice, history.NewFlatPrice, history.ApprovedLevel1By, history.ApprovedLevel2By, history.DocNo));
}
[Fact]
public async Task PriceAdjustment_ItemRepricedSinceTheRequest_IsRefusedAsStale()
{
var h = await Harness.CreateAsync();
var id = (await h.Prices.CreateAsync(PriceRequest(flat: 120m))).PriceAdjustmentId;
h.User.Be(ApproverA);
await h.Prices.ApproveAsync(id, null);
var item = await h.Db.Items.FirstAsync();
item.SellingFlatPrice = 105m; // someone changed it in between
await h.Db.SaveChangesAsync();
h.User.Be(ApproverB);
var stale = await Assert.ThrowsAsync<DomainException>(() => h.Prices.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.PriceAdjustmentStale, stale.Code);
Assert.Empty(await h.Prices.ItemHistoryAsync(ItemId));
}
[Fact]
public async Task PriceAdjustment_OnePendingRequestPerItem()
{
var h = await Harness.CreateAsync();
await h.Prices.CreateAsync(PriceRequest(flat: 120m));
var second = await Assert.ThrowsAsync<DomainException>(() => h.Prices.CreateAsync(PriceRequest(flat: 130m)));
Assert.Equal(ErrorCodes.PriceAdjustmentItemPending, second.Code);
}
[Theory]
[InlineData(100, 120, 20)]
[InlineData(100, 80, 20)]
[InlineData(-1, 50, 100)] // setting a price where there was none (-1 = no price)
[InlineData(50, -1, 100)] // clearing one
[InlineData(40, 40, 0)]
public void ChangePercent_MeasuresTheSizeOfAPriceChange(int oldPrice, int newPrice, int expected)
{
decimal? Price(int v) => v < 0 ? null : v;
Assert.Equal(expected, PriceAdjustmentService.ChangePercent(Price(oldPrice), Price(newPrice)));
}
[Theory]
[InlineData(2, 0, 5000, 2)] // no threshold: always two
[InlineData(2, 1000, 50, 1)] // below threshold: one
[InlineData(2, 1000, 1000, 2)] // at threshold: two
[InlineData(1, 1000, 5000, 1)]
[InlineData(0, 0, 5000, 0)]
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
=> Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
// --- helpers ------------------------------------------------------------------------------
private static CreateAdjustmentRequest Request(decimal qty) => new()
{
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = ReasonId,
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
};
private static CreatePriceAdjustmentRequest PriceRequest(decimal flat) => new()
{
Reason = "Supplier price rise",
Lines = [new PriceAdjustmentLineInput { ItemId = ItemId, NewMinPrice = 90m, NewMaxPrice = 150m, NewFlatPrice = flat }]
};
private sealed class Harness
{
public required ErpDbContext Db { get; init; }
public required SwitchableUser User { get; init; }
public required RecordingMutator Mutator { get; init; }
public required FakeGeneralLedger Gl { get; init; }
public required RecordingNotifications Notifications { get; init; }
public required AdjustmentService Stock { get; init; }
public required PriceAdjustmentService Prices { get; init; }
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
{
var db = new ErpDbContext(
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
new TestCurrentUser(), new CurrentBranchContext(new TestCurrentUser()));
db.Roles.AddRange(
new Role { RoleId = 1, Code = "Requester", Name = "Requester" },
new Role { RoleId = ApproverRoleId, Code = "Approver", Name = "Approver" },
new Role { RoleId = ClerkRoleId, Code = "Clerk", Name = "Clerk" });
db.Users.AddRange(
new User { UserId = ERPCore.Domain.Entities.User.SystemUserId, Username = "system", DisplayName = "System" },
new User { UserId = Requester, Username = "req", DisplayName = "Requester", RoleId = 1 },
new User { UserId = ApproverA, Username = "a", DisplayName = "Approver A", RoleId = ApproverRoleId },
new User { UserId = ApproverB, Username = "b", DisplayName = "Approver B", RoleId = ApproverRoleId },
new User { UserId = Clerk, Username = "c", DisplayName = "Clerk", RoleId = ClerkRoleId });
db.Permissions.AddRange(
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments" },
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments" });
foreach (var roleId in new[] { ApproverRoleId, ClerkRoleId })
foreach (var permissionId in new[] { 45, 58 })
db.RolePermissions.Add(new RolePermission { RoleId = roleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 45, Actions = RolePermissionAction.View | RolePermissionAction.Create });
db.Branches.Add(new Branch { BranchId = 1, Code = "BR-001", Name = "Main" });
db.Warehouses.Add(new Warehouse { WarehouseId = WarehouseId, Code = "WH-1", Name = "Main Store", BranchId = 1 });
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
db.ReasonCodes.Add(new ReasonCode { ReasonCodeId = ReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
db.ApprovalConfig.Add(new ApprovalConfig
{
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
});
await db.SaveChangesAsync();
var user = new SwitchableUser(Requester, "Requester");
var uow = new PassThroughUnitOfWork(db);
var notifications = new RecordingNotifications();
var approvals = new ApprovalService(
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
new Repository<RolePermission>(db), user, notifications, uow);
var gl = new FakeGeneralLedger();
var mutator = new RecordingMutator();
var numbers = new FakeNumberSequence();
var stock = new AdjustmentService(
new Repository<StockAdjustment>(db), new Repository<Warehouse>(db), new Repository<Item>(db),
new Repository<ReasonCode>(db), new Repository<StockLedger>(db), mutator, new FixedValuation(),
numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
new ConfigurationBuilder().Build(),
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), user));
var prices = new PriceAdjustmentService(
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db),
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow);
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
}
}
/// <summary>A signed-in user whose identity the test switches between steps.</summary>
private sealed class SwitchableUser(int userId, string roleCode) : ICurrentUser
{
private static readonly Dictionary<int, string> Roles = new()
{
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
};
public int AuditUserId { get; private set; } = userId;
public string? RoleCode { get; private set; } = roleCode;
public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true;
public bool IsAdmin => false;
public int? BranchId => null;
public void Be(int userId)
{
AuditUserId = userId;
RoleCode = Roles[userId];
}
}
/// <summary>Records what would be posted to stock; every unit is worth 10.00.</summary>
private sealed class RecordingMutator : IStockMutator
{
public List<IReadOnlyList<StockDelta>> Calls { get; } = new();
public Task<IReadOnlyList<StockLedger>> ApplyAsync(int warehouseId, string sourceDocType, int sourceDocId, DateTime now,
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default)
{
Calls.Add(deltas);
IReadOnlyList<StockLedger> refs = deltas.Select(d => new StockLedger
{
ItemId = d.ItemId, WarehouseId = warehouseId, QtyBase = Math.Abs(d.QtyDelta), UnitCost = 10m, Value = Math.Abs(d.QtyDelta) * 10m,
Direction = d.QtyDelta < 0 ? Direction.Out : Direction.In, SourceDocType = sourceDocType, SourceDocId = sourceDocId, CreatedAt = now
}).ToList();
return Task.FromResult(refs);
}
}
/// <summary>10 units on hand worth 100.00 — 10.00 each, which prices requests for the threshold.</summary>
private sealed class FixedValuation : IFifoCostingService
{
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> Task.FromResult(new StockValuationDto(itemId, warehouseId, [], 10m, 100m, "LKR", "FIFO"));
public Task<StockLayer> CreateInboundLayerAsync(int itemId, int warehouseId, int? batchId, int? serialId, int? grnLineId,
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null) => throw new NotSupportedException();
public Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException();
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
}
private sealed class RecordingNotifications : INotificationService
{
public List<(IReadOnlyList<int> UserIds, string ModuleKey, string Message)> Targeted { get; } = new();
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null,
CancellationToken ct = default) => Task.CompletedTask;
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message,
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default)
{
Targeted.Add((userIds.ToList(), moduleKey, message));
return Task.CompletedTask;
}
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
}
}
@@ -0,0 +1,116 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.Tests.TestSupport;
namespace ERPCore.Tests;
/// <summary>Low-stock / reorder alerts raised after stock issues.</summary>
public class LowStockAlertServiceTests
{
private const int ItemId = 1, WarehouseId = 1;
[Theory]
[InlineData(12, 4, 8, true)] // 12 → 8 crosses the alert level of 10
[InlineData(12, 2, 10, true)] // landing exactly on the level counts
[InlineData(20, 5, 15, false)] // still above the level
[InlineData(9, 3, 6, false)] // was already low — no repeat alert on every sale
public async Task Alerts_OnlyWhenAnIssueCrossesTheReorderPoint(decimal before, decimal issued, decimal after, bool alerts)
{
Assert.Equal(before - issued, after);
await using var db = Seed();
var tracker = new LowStockTracker();
tracker.Track(ItemId, WarehouseId, issued);
var notifications = new RecordingNotifications();
await Service(db, tracker, after, notifications).CheckAsync();
if (!alerts)
{
Assert.Empty(notifications.Raised);
return;
}
var raised = Assert.Single(notifications.Raised);
Assert.Equal(LowStockAlertService.ModuleKey, raised.ModuleKey);
Assert.Contains("SKU-1", raised.Message);
Assert.Contains($"down to {after:0.####} pcs at WH-1", raised.Message);
Assert.Contains("Suggested reorder: 50 pcs", raised.Message);
}
[Fact]
public async Task ItemsWithoutARule_OrUntouched_AreIgnored()
{
await using var db = Seed();
var tracker = new LowStockTracker();
tracker.Track(itemId: 99, WarehouseId, 100); // no rule for this item
var notifications = new RecordingNotifications();
await Service(db, tracker, available: 0, notifications).CheckAsync();
await Service(db, new LowStockTracker(), available: 0, notifications).CheckAsync(); // nothing issued
Assert.Empty(notifications.Raised);
}
[Fact]
public async Task Tracker_SumsIssuesWithinARequest_AndEmptiesWhenDrained()
{
var tracker = new LowStockTracker();
tracker.Track(ItemId, WarehouseId, 3);
tracker.Track(ItemId, WarehouseId, 4);
tracker.Track(ItemId, WarehouseId, 0);
Assert.Equal(7m, tracker.Drain()[(ItemId, WarehouseId)]);
Assert.Empty(tracker.Drain());
}
private static LowStockAlertService Service(ErpDbContext db, LowStockTracker tracker, decimal available, INotificationService notifications)
=> new(tracker, new Repository<ItemReorder>(db), new FixedStock(available), notifications);
private static ErpDbContext Seed()
{
var db = BankDepositTestSupport.CreateContext(); // users, branches, warehouses WH-1 / WH-2
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola 500ml", BaseUomId = 1, CategoryId = 1 });
db.ItemReorders.Add(new ItemReorder { ReorderId = 1, ItemId = ItemId, WarehouseId = WarehouseId, ReorderPoint = 10, ReorderQty = 50 });
db.SaveChanges();
return db;
}
private sealed class FixedStock(decimal available) : IStockService
{
public Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> Task.FromResult(new StockOnHandDto(itemId, warehouseId, available, available, 0, 0, 0, DateTime.UtcNow));
public Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) => throw new NotSupportedException();
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private sealed class RecordingNotifications : INotificationService
{
public List<(string ModuleKey, string Title, string Message)> Raised { get; } = new();
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null,
int? relatedEntityId = null, CancellationToken ct = default)
{
Raised.Add((moduleKey, title, message));
return Task.CompletedTask;
}
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message,
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
}
}
@@ -0,0 +1,168 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Tests.TestSupport;
/// <summary>Records every journal it is asked to post; can be told to reject them instead.</summary>
internal sealed class FakeGeneralLedger : IGeneralLedgerService
{
public const long CashAccountId = 1, BankAccountId = 2, OtherBankAccountId = 3;
public List<GlJournalEntryRequest> Posted { get; } = new();
public bool Reject { get; set; }
public IReadOnlyList<GlBankAccount> Accounts { get; init; } =
[
new("Cash", CashAccountId, "Main Till", null, "Petty", null, 101, "1000-01", "LKR"),
new("Bank", BankAccountId, "BOC Current", "Bank of Ceylon", null, "0001234567", 102, "1010-01", "LKR"),
new("Bank", OtherBankAccountId, "HNB Savings", "Hatton National Bank", null, "9876543210", 103, "1010-02", "LKR")
];
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
{
if (Reject) throw new DomainException(ErrorCodes.Validation, "GL rejected the entry.", 502);
Posted.Add(request);
return Task.FromResult(new GlJournalEntryResult($"JE-{Posted.Count}", true));
}
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts);
}
/// <summary>The in-memory provider has no transactions: run the work, then save — what the real one commits.</summary>
internal sealed class PassThroughUnitOfWork(ErpDbContext db) : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default) => db.SaveChangesAsync(ct);
public async Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
await action(ct);
await db.SaveChangesAsync(ct);
}
public async Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
{
var result = await action(ct);
await db.SaveChangesAsync(ct);
return result;
}
}
internal sealed class FakeNumberSequence : INumberSequenceService
{
private int _next;
public Task<string> NextAsync(string docType, int branchId, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
public Task<string> NextAsync(string docType, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
}
/// <summary>Branching on: the requested branch is used as-is.</summary>
internal sealed class FakeBranchConfig : IBranchConfigService
{
public Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<ETagged<BranchConfigDto>> UpdateAsync(UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
public Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
=> requestedBranchId > 0 ? Task.FromResult(requestedBranchId) : throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
public Task<bool> IsWarehouseBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
public Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default) => Task.FromResult(requestedBranchId);
public Task<int> ResolveDefaultBranchIdAsync(CancellationToken ct = default) => Task.FromResult(1);
}
/// <summary>Seeds and services for the cash-in-hand / bank-deposit tests. Two branches, one warehouse each.</summary>
internal static class BankDepositTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public const int Branch1 = 1, Branch2 = 2;
public const int Warehouse1 = 1, Warehouse2 = 2;
public static BusinessDayClock Clock { get; } = new(Config());
public static IConfiguration Config(DateOnly? trackingStart = null) => new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DayEndReport:TimeZone"] = "Asia/Colombo",
["BankDeposit:DefaultCashAccountCode"] = "1000-01",
["BankDeposit:CashTrackingStartDate"] = trackingStart?.ToString("yyyy-MM-dd")
})
.Build();
/// <summary>Midday on a local business day, as the UTC instant documents are stamped with.</summary>
public static DateTime At(DateOnly day) => Clock.ToUtc(day).AddHours(6);
public static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
var currentUser = new TestCurrentUser();
var db = new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
db.Branches.AddRange(
new Branch { BranchId = Branch1, Code = "BR-001", Name = "Colombo" },
new Branch { BranchId = Branch2, Code = "BR-002", Name = "Kandy" });
db.Warehouses.AddRange(
new Warehouse { WarehouseId = Warehouse1, Code = "WH-1", Name = "Colombo Store", BranchId = Branch1 },
new Warehouse { WarehouseId = Warehouse2, Code = "WH-2", Name = "Kandy Store", BranchId = Branch2 });
db.SaveChanges();
return db;
}
private static int _id;
public static SalesSlip CashSlip(DateOnly day, decimal cash, decimal card = 0m, int warehouseId = Warehouse1)
{
var id = Interlocked.Increment(ref _id);
return new SalesSlip
{
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = At(day), Status = SalesSlipStatus.Posted, WarehouseId = warehouseId,
GrandTotal = cash + card, PaidAmount = cash + card, CashAmount = cash, CardAmount = card
};
}
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db),
new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
{
var currentUser = new TestCurrentUser();
return new BankDepositService(
new Repository<BankDeposit>(db), new Repository<Branch>(db), CashService(db, trackingStart), gl,
currentUser, new CurrentBranchContext(currentUser), new FakeNumberSequence(), new FakeBranchConfig(),
new PassThroughUnitOfWork(db), db, Clock);
}
public static BankDeposit Deposit(DateOnly businessDate, decimal amount, int branchId = Branch1,
BankDepositStatus status = BankDepositStatus.Posted)
{
var id = Interlocked.Increment(ref _id);
return new BankDeposit
{
BankDepositId = id, DocNo = $"BD-{id}", BranchId = branchId, BusinessDate = businessDate, DepositDate = businessDate,
Amount = amount, Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current", CreatedBy = User.SystemUserId
};
}
}
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Dtos.Approvals;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10). The inbox is open to every
/// signed-in user — it only lists what they may approve and their own requests. Reading the
/// settings is open too (screens need to know whether prices are locked); changing them needs
/// <c>settings.approvals</c>.
/// </summary>
[Route("api/v1/approvals")]
public sealed class ApprovalsController : ApiControllerBase
{
private readonly ApprovalInboxService _inbox;
private readonly IApprovalService _approvals;
public ApprovalsController(ApprovalInboxService inbox, IApprovalService approvals)
{
_inbox = inbox;
_approvals = approvals;
}
[HttpGet("pending")]
[ProducesResponseType(typeof(PendingApprovalsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<PendingApprovalsDto>> Pending(CancellationToken ct)
=> Ok(await _inbox.PendingAsync(ct));
[HttpGet("config")]
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ApprovalConfigDto>> GetConfig(CancellationToken ct)
=> Ok(await _approvals.GetConfigAsync(ct));
[HttpPut("config")]
[Authorize(Policy = NavPolicies.Prefix + "settings.approvals")]
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ApprovalConfigDto>> UpdateConfig([FromBody] UpdateApprovalConfigRequest request, CancellationToken ct)
=> Ok(await _approvals.UpdateConfigAsync(request, ct));
}
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Till cash deposited to the bank, and the cash in hand behind it (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3).
/// Recording needs the Create action on <c>accounts.bank-deposits</c>; cancelling needs Delete.
/// </summary>
[Route("api/v1/bank-deposits")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits")]
public sealed class BankDepositsController : ApiControllerBase
{
private readonly IBankDepositService _deposits;
private readonly ICashInHandService _cash;
public BankDepositsController(IBankDepositService deposits, ICashInHandService cash)
{
_deposits = deposits;
_cash = cash;
}
/// <summary>Deposits filtered by business date range, branch, bank account and status, plus their total.</summary>
[HttpGet]
[ProducesResponseType(typeof(BankDepositListDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BankDepositListDto>> List(
[FromQuery] PageQuery query, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] int? branchId,
[FromQuery] long? bankAccountId, [FromQuery] BankDepositStatus? status, CancellationToken ct)
=> Ok(await _deposits.ListAsync(query, new BankDepositListFilter(from, to, branchId, bankAccountId, status), ct));
[HttpGet("{bankDepositId:int}")]
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BankDepositDto>> GetById(int bankDepositId, CancellationToken ct)
{
var result = await _deposits.GetAsync(bankDepositId, ct);
return result is null ? NotFound() : Ok(result);
}
/// <summary>One business day's cash position (defaults to today) and how much of it can still be deposited.</summary>
[HttpGet("cash-in-hand")]
[ProducesResponseType(typeof(CashInHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CashInHandDto>> CashInHand([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _cash.GetPositionAsync(date, branchId, ct));
/// <summary>Day-wise cash position for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
[HttpGet("cash-in-hand/daily")]
[ProducesResponseType(typeof(CashInHandDailyDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CashInHandDailyDto>> CashInHandDaily(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _cash.GetDailyAsync(from, to, branchId, ct));
/// <summary>Record a deposit; posts GL (Debit bank / Credit cash). Rejected above the cash in hand.</summary>
[HttpPost]
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<BankDepositDto>> Create([FromBody] CreateBankDepositRequest request, CancellationToken ct)
{
var result = await _deposits.CreateAsync(request, ct);
return Created($"/api/v1/bank-deposits/{result.BankDepositId}", result);
}
/// <summary>Cancel a Posted deposit; posts the reversing GL entry and puts the cash back in hand.</summary>
[HttpPost("{bankDepositId:int}/cancel")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BankDepositDto>> Cancel(int bankDepositId, [FromBody] CancelBankDepositRequest request, CancellationToken ct)
=> Ok(await _deposits.CancelAsync(bankDepositId, request, ct));
}
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Dtos.Reports;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Day-End Sales Report — a day's sales, payments by method, expenses and income, and the
/// same day-wise over a range for the income chart/PDF (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
[Route("api/v1/day-end-report")]
[Authorize(Policy = NavPolicies.Prefix + "sales.day-end-report")]
public sealed class DayEndReportController : ApiControllerBase
{
private readonly IDayEndReportService _reports;
public DayEndReportController(IDayEndReportService reports) => _reports = reports;
/// <summary>One business day (defaults to today in the report's time zone).</summary>
[HttpGet]
[ProducesResponseType(typeof(DayEndReportDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DayEndReportDto>> GetDay([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _reports.GetDayAsync(date, branchId, ct));
/// <summary>Day-wise figures for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
[HttpGet("daily")]
[ProducesResponseType(typeof(DayEndDailyReportDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<DayEndDailyReportDto>> GetDaily([FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _reports.GetDailyAsync(from, to, branchId, ct));
}
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Expense categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup"). Reading is open to
/// anyone with Accounts access (the expense form needs the lists); changes need the
/// Expense Setup permission. Categories are the fixed seeded four — there is no create
/// endpoint; only their active flag can change. Titles can be added under them.
/// </summary>
[Route("api/v1")]
[Authorize(Policy = NavPolicies.Prefix + "accounts")]
public sealed class ExpenseSetupController : ApiControllerBase
{
private const string SetupPolicy = NavPolicies.Prefix + "accounts.expense-setup";
private readonly IExpenseSetupService _setup;
public ExpenseSetupController(IExpenseSetupService setup) => _setup = setup;
/// <summary>Categories, each with its titles, in display order.</summary>
[HttpGet("expense-categories")]
[ProducesResponseType(typeof(IReadOnlyList<ExpenseCategoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<ExpenseCategoryDto>>> List([FromQuery] bool includeInactive, CancellationToken ct)
=> Ok(await _setup.ListAsync(includeInactive, ct));
[HttpPut("expense-categories/{expenseCategoryId:int}")]
[Authorize(Policy = SetupPolicy)]
[ProducesResponseType(typeof(ExpenseCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseCategoryDto>> UpdateCategory(int expenseCategoryId, [FromBody] UpdateExpenseCategoryRequest request, CancellationToken ct)
=> Ok(await _setup.UpdateCategoryAsync(expenseCategoryId, request, ct));
[HttpPost("expense-categories/{expenseCategoryId:int}/titles")]
[Authorize(Policy = SetupPolicy)]
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseTitleDto>> CreateTitle(int expenseCategoryId, [FromBody] CreateExpenseTitleRequest request, CancellationToken ct)
{
var dto = await _setup.CreateTitleAsync(expenseCategoryId, request, ct);
return Created($"/api/v1/expense-titles/{dto.ExpenseTitleId}", dto);
}
[HttpPut("expense-titles/{expenseTitleId:int}")]
[Authorize(Policy = SetupPolicy)]
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseTitleDto>> UpdateTitle(int expenseTitleId, [FromBody] UpdateExpenseTitleRequest request, CancellationToken ct)
=> Ok(await _setup.UpdateTitleAsync(expenseTitleId, request, ct));
}
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Business expense bills — draft, post (GL accrual), pay, cancel (docs/EXPENSE_MODULE_PLAN.md §6).</summary>
[Route("api/v1/expenses")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.expenses")]
public sealed class ExpensesController : ApiControllerBase
{
private readonly IExpenseService _expenses;
private readonly IExpensePaymentService _payments;
public ExpensesController(IExpenseService expenses, IExpensePaymentService payments)
{
_expenses = expenses;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ExpenseSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ExpenseSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] ExpenseStatus? status, [FromQuery] ExpensePaymentStatus? paymentStatus,
[FromQuery] int? categoryId, [FromQuery] int? titleId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] bool overdueOnly, CancellationToken ct)
=> Ok(await _expenses.ListAsync(query, new ExpenseListFilter(status, paymentStatus, categoryId, titleId, from, to, overdueOnly), ct));
/// <summary>Totals by category → title for Posted expenses dated in [from, to] (default: this month), plus unpaid/overdue position.</summary>
[HttpGet("summary")]
[ProducesResponseType(typeof(ExpenseSummaryReportDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ExpenseSummaryReportDto>> Summary([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _expenses.GetSummaryAsync(from, to, ct));
[HttpGet("{expenseId:int}")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ExpenseDto>> GetById(int expenseId, CancellationToken ct)
{
var result = await _expenses.GetAsync(expenseId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ExpenseDto>> Create([FromBody] CreateExpenseRequest request, CancellationToken ct)
{
var result = await _expenses.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/expenses/{result.Value.ExpenseId}", result.Value);
}
[HttpPut("{expenseId:int}")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ExpenseDto>> Update(int expenseId, [FromBody] UpdateExpenseRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _expenses.UpdateAsync(expenseId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Draft only.</summary>
[HttpDelete("{expenseId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int expenseId, CancellationToken ct)
{
await _expenses.DeleteAsync(expenseId, ct);
return NoContent();
}
/// <summary>Accrue the expense to GL (Debit expense account / Credit Accrued Expenses).</summary>
[HttpPost("{expenseId:int}/post")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseDto>> Post(int expenseId, CancellationToken ct)
=> Ok(await _expenses.PostAsync(expenseId, ct));
/// <summary>Post a Draft and pay it, in full or partially, in one transaction; any remainder stays as the balance.</summary>
[HttpPost("{expenseId:int}/post-and-pay")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseDto>> PostAndPay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
=> Ok(await _payments.PostAndPayAsync(expenseId, request, ct));
/// <summary>Draft, or Posted with no payments (posts the reversing GL entry).</summary>
[HttpPost("{expenseId:int}/cancel")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseDto>> Cancel(int expenseId, [FromBody] CancelExpenseRequest request, CancellationToken ct)
=> Ok(await _expenses.CancelAsync(expenseId, request, ct));
/// <summary>Pay against the balance, in full or in installments; one GL entry per submission.</summary>
[HttpPost("{expenseId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> Pay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
{
var rows = await _payments.PayAsync(expenseId, request, ct);
return Created($"/api/v1/expenses/{expenseId}/payments", rows);
}
/// <summary>Payment history, newest first.</summary>
[HttpGet("{expenseId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> ListPayments(int expenseId, CancellationToken ct)
=> Ok(await _payments.ListAsync(expenseId, ct));
}
@@ -57,6 +57,13 @@ public sealed class ItemsController : ApiControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
/// <summary>The item's latest posted GRN receipts (default 5, newest first): unit cost, discount, net cost, vendor.</summary>
[HttpGet("{itemId:int}/purchase-prices")]
[ProducesResponseType(typeof(IReadOnlyList<ItemPurchasePriceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<ItemPurchasePriceDto>>> PurchasePrices(int itemId, [FromQuery] int take, CancellationToken ct)
=> Ok(await _items.GetPurchasePricesAsync(itemId, take <= 0 ? 5 : take, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary> /// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:int}")] [HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Products;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6.4). Requesting needs Create on
/// <c>products.price-adjustments</c>, deciding needs Approve; an item's applied price history is
/// readable by anyone who can see Products.
/// </summary>
[Route("api/v1/price-adjustments")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class PriceAdjustmentsController : ApiControllerBase
{
private const string Screen = NavPolicies.Prefix + "products.price-adjustments";
private const string CreatePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.CreateAction;
private const string ApprovePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
private readonly IPriceAdjustmentService _prices;
public PriceAdjustmentsController(IPriceAdjustmentService prices) => _prices = prices;
[HttpGet]
[Authorize(Policy = Screen)]
[ProducesResponseType(typeof(PagedResponse<PriceAdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PriceAdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PriceAdjustmentStatus? status, [FromQuery] int? itemId, CancellationToken ct)
=> Ok(await _prices.ListAsync(query, status, itemId, ct));
[HttpGet("{priceAdjustmentId:int}")]
[Authorize(Policy = Screen)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PriceAdjustmentDto>> GetById(int priceAdjustmentId, CancellationToken ct)
{
var dto = await _prices.GetAsync(priceAdjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Request new selling prices for one or more items.</summary>
[HttpPost]
[Authorize(Policy = CreatePolicy)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Create([FromBody] CreatePriceAdjustmentRequest request, CancellationToken ct)
{
var dto = await _prices.CreateAsync(request, ct);
return Created($"/api/v1/price-adjustments/{dto.PriceAdjustmentId}", dto);
}
/// <summary>Give the next approval; the final one applies the prices.</summary>
[HttpPost("{priceAdjustmentId:int}/approve")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Approve(int priceAdjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
=> Ok(await _prices.ApproveAsync(priceAdjustmentId, request?.Comment, ct));
[HttpPost("{priceAdjustmentId:int}/reject")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Reject(int priceAdjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
=> Ok(await _prices.RejectAsync(priceAdjustmentId, request.Reason, ct));
/// <summary>The requester withdraws a pending request.</summary>
[HttpPost("{priceAdjustmentId:int}/cancel")]
[Authorize(Policy = Screen)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Cancel(int priceAdjustmentId, CancellationToken ct)
=> Ok(await _prices.CancelAsync(priceAdjustmentId, ct));
/// <summary>Every applied price change for an item, newest first, with who approved it.</summary>
[HttpGet("/api/v1/items/{itemId:int}/price-history")]
[ProducesResponseType(typeof(IReadOnlyList<ItemPriceHistoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<ItemPriceHistoryDto>>> ItemHistory(int itemId, CancellationToken ct)
=> Ok(await _prices.ItemHistoryAsync(itemId, ct));
}
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock; using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
@@ -7,23 +9,30 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers; namespace ERPCore.Controllers;
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary> /// <summary>
/// Stock-adjustment endpoints (docs/11 §5.5). Creating submits a request; it moves stock once
/// approved (docs/DUAL_APPROVAL_PLAN.md §5.4). Approving or rejecting needs the Approve action on
/// <c>stock.adjustments</c>; the two-person rules are enforced by the service.
/// </summary>
[Route("api/v1/stock-adjustments")] [Route("api/v1/stock-adjustments")]
[Authorize(Policy = NavPolicies.Prefix + "stock")] [Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockAdjustmentsController : ApiControllerBase public sealed class StockAdjustmentsController : ApiControllerBase
{ {
private const string ApprovePolicy = NavPolicies.Prefix + "stock.adjustments" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
private readonly IAdjustmentService _adjustments; private readonly IAdjustmentService _adjustments;
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments; public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List posted adjustments, newest first.</summary> /// <summary>List adjustments (posted and requests), newest first.</summary>
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List( public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct) [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct)); [FromQuery] AdjustmentStatus? status, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary> /// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")] [HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -33,7 +42,7 @@ public sealed class StockAdjustmentsController : ApiControllerBase
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary> /// <summary>Submit an adjustment (mandatory reason code). Posts at once only when approvals are off.</summary>
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -44,4 +53,29 @@ public sealed class StockAdjustmentsController : ApiControllerBase
var dto = await _adjustments.CreateAsync(request, ct); var dto = await _adjustments.CreateAsync(request, ct);
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto); return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
} }
/// <summary>Give the next approval. The final one posts the stock movement and GL.</summary>
[HttpPost("{adjustmentId:int}/approve")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AdjustmentDto>> Approve(int adjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
=> Ok(await _adjustments.ApproveAsync(adjustmentId, request?.Comment, ct));
[HttpPost("{adjustmentId:int}/reject")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AdjustmentDto>> Reject(int adjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
=> Ok(await _adjustments.RejectAsync(adjustmentId, request.Reason, ct));
/// <summary>The requester withdraws a pending request.</summary>
[HttpPost("{adjustmentId:int}/cancel")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AdjustmentDto>> Cancel(int adjustmentId, CancellationToken ct)
=> Ok(await _adjustments.CancelAsync(adjustmentId, ct));
} }
+9
View File
@@ -26,4 +26,13 @@ public static class DocumentTypes
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary> /// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
public const string SalesDayEnd = "DEND"; public const string SalesDayEnd = "DEND";
/// <summary>Business expense bill (Accounts) — <c>EXP-BR-001-2026-00001</c>.</summary>
public const string Expense = "EXP";
/// <summary>Till cash deposited to a bank account (Accounts) — <c>BD-BR-001-2026-00001</c>.</summary>
public const string BankDeposit = "BD";
/// <summary>Selling-price change request (Products) — <c>PADJ-2026-00001</c>.</summary>
public const string PriceAdjustment = "PADJ";
} }
@@ -0,0 +1,55 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Till cash taken to the bank (docs/BANK_DEPOSIT_MODULE_PLAN.md §4). Saving it posts a GL entry
/// (Debit the bank account / Credit the cash account) and lowers the branch's cash in hand for
/// <see cref="BusinessDate"/>. There is no draft: a wrong deposit is cancelled (the entry is
/// reversed) and entered again. GL accounts have no local FK, so the account details are snapshots.
/// </summary>
public class BankDeposit
{
public int BankDepositId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int BranchId { get; set; }
public Branch? Branch { get; set; }
/// <summary>The day whose cash is deposited — cash in hand and the day-end report group by this.</summary>
public DateOnly BusinessDate { get; set; }
/// <summary>The day the bank received it; the GL entry date.</summary>
public DateOnly DepositDate { get; set; }
public decimal Amount { get; set; }
public long FromGlCashAccountId { get; set; }
public string FromCashAccountCode { get; set; } = string.Empty;
public string FromCashAccountName { get; set; } = string.Empty;
public long ToGlBankAccountId { get; set; }
public string ToBankAccountCode { get; set; } = string.Empty;
public string ToBankAccountName { get; set; } = string.Empty;
public string? ToBankName { get; set; }
public string? ToAccountNumber { get; set; }
public string? DepositSlipNo { get; set; }
public string? DepositedBy { get; set; }
public string? Remarks { get; set; }
public BankDepositStatus Status { get; set; } = BankDepositStatus.Posted;
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public string? GlReversalJournalNo { get; set; }
public string? CancelReason { get; set; }
public int? CancelledBy { get; set; }
public DateTime? CancelledAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -25,6 +25,11 @@ public class BundleSale
public decimal DiscountTotal { get; set; } public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; } public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; } public decimal GrandTotal { get; set; }
/// <summary>How the (always fully paid) <see cref="GrandTotal"/> was tendered:
/// <c>CashAmount + CardAmount == GrandTotal</c>. Backfilled as all cash.</summary>
public decimal CashAmount { get; set; }
public decimal CardAmount { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; } public uint RowVersion { get; set; }
@@ -0,0 +1,48 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One approval decision on a document (docs/DUAL_APPROVAL_PLAN.md §4.1) — a row per decision, so
/// the full trail survives. Polymorphic like the stock ledger: <see cref="DocType"/> +
/// <see cref="DocId"/>, no FK. Doc types: <c>ADJ</c> (stock adjustment), <c>PADJ</c> (price adjustment).
/// </summary>
public class DocumentApproval
{
public int DocumentApprovalId { get; set; }
public string DocType { get; set; } = string.Empty;
public int DocId { get; set; }
/// <summary>1 or 2.</summary>
public int Level { get; set; }
public ApprovalDecision Decision { get; set; }
public int DecidedBy { get; set; }
public User? Decider { get; set; }
public DateTime DecidedAt { get; set; }
public string? Comment { get; set; }
}
/// <summary>
/// Approval rules (docs/DUAL_APPROVAL_PLAN.md §7). Singleton row, edited under Settings → Approvals.
/// Levels: 0 = no approval (applies on create), 1 = one approver, 2 = two different approvers.
/// </summary>
public class ApprovalConfig
{
public const int SingletonId = 1;
public int ApprovalConfigId { get; set; }
public int StockAdjustmentLevels { get; set; } = 2;
/// <summary>With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.</summary>
public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
public int PriceAdjustmentLevels { get; set; } = 2;
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary>
public string? Level2RoleCodes { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,92 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One business expense bill (electricity, rent, …) outside procurement. Draft is editable;
/// Post accrues it to GL (Debit the title's expense account / Credit Accrued Expenses);
/// payments then settle the accrual in one or more installments (see
/// ExpensePaymentService). Model: docs/EXPENSE_MODULE_PLAN.md §3.3.
/// </summary>
public class Expense
{
public int ExpenseId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public DateOnly ExpenseDate { get; set; }
public DateOnly? DueDate { get; set; }
public int ExpenseCategoryId { get; set; }
public ExpenseCategory? Category { get; set; }
public int ExpenseTitleId { get; set; }
public ExpenseTitle? Title { get; set; }
public string? Payee { get; set; }
public int? VendorId { get; set; }
public Vendor? Vendor { get; set; }
public string? BillReference { get; set; }
public string? Description { get; set; }
/// <summary>Optional month the bill covers, as <c>yyyy-MM</c> (e.g. September's electricity).</summary>
public string? PeriodMonth { get; set; }
public decimal Amount { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
public ExpensePaymentStatus PaymentStatus { get; set; } = ExpensePaymentStatus.Unpaid;
/// <summary>Snapshot of the title's GL expense account at post time.</summary>
public string? GlExpenseAccountCode { get; set; }
/// <summary>Journal number of the accrual entry posted on Post.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
/// <summary>Journal number of the reversing entry posted on Cancel (Posted expenses only).</summary>
public string? GlReversalJournalNo { get; set; }
public string? CancelReason { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<ExpensePayment> Payments { get; set; } = new List<ExpensePayment>();
}
/// <summary>
/// A payment against a Posted expense's balance (installments allowed). Every line of one
/// payment submission shares the single GL journal entry that submission posted (Debit
/// Accrued Expenses / Credit each selected cash-or-bank account). Mirrors
/// <see cref="PurchaseInvoicePayment"/>.
/// </summary>
public class ExpensePayment
{
public int ExpensePaymentId { get; set; }
public int ExpenseId { get; set; }
public Expense? Expense { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; }
public ExpensePaymentMethod PaymentMethod { get; set; }
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
public long GlBankAccountId { get; set; }
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
public string BankAccountName { get; set; } = string.Empty;
public string? ChequeNo { get; set; }
public string? Reference { get; set; }
public string? GlJournalNo { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,48 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Top-level expense grouping (Utility, Rental, Salaries, Other). The four seeded rows are
/// <see cref="IsSystem"/> — they can be deactivated but not renamed; admins can add more.
/// Each category owns its <see cref="ExpenseTitle"/>s. Model: docs/EXPENSE_MODULE_PLAN.md §3.1.
/// </summary>
public class ExpenseCategory
{
public int ExpenseCategoryId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsSystem { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<ExpenseTitle> Titles { get; set; } = new List<ExpenseTitle>();
}
/// <summary>
/// A specific expense under a category (Utility → Electricity, Water, …). Carries the GL
/// expense account debited when an expense with this title is posted — snapshotted onto the
/// expense at post time, so changing it later does not touch already-posted expenses.
/// Model: docs/EXPENSE_MODULE_PLAN.md §3.2.
/// </summary>
public class ExpenseTitle
{
public int ExpenseTitleId { get; set; }
public int ExpenseCategoryId { get; set; }
public ExpenseCategory? Category { get; set; }
public string Name { get; set; } = string.Empty;
public string GlExpenseAccountCode { get; set; } = string.Empty;
public bool IsSystem { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,78 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A request to change items' selling prices (docs/DUAL_APPROVAL_PLAN.md §6). Nothing changes on the
/// items until it is fully approved; then each line's new prices are written to its item and an
/// <see cref="ItemPriceHistory"/> row is kept. <see cref="CreatedBy"/> is the requester.
/// </summary>
public class PriceAdjustment
{
public int PriceAdjustmentId { get; set; }
public string DocNo { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
public PriceAdjustmentStatus Status { get; set; } = PriceAdjustmentStatus.PendingApproval;
public int RequiredApprovals { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public int? ApprovedLevel1By { get; set; }
public DateTime? ApprovedLevel1At { get; set; }
public int? ApprovedLevel2By { get; set; }
public DateTime? ApprovedLevel2At { get; set; }
public int? RejectedBy { get; set; }
public DateTime? RejectedAt { get; set; }
public string? RejectReason { get; set; }
public DateTime? CancelledAt { get; set; }
/// <summary>When the new prices were written to the items.</summary>
public DateTime? AppliedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<PriceAdjustmentLine> Lines { get; set; } = new List<PriceAdjustmentLine>();
}
/// <summary>One item's change. The Old* values are the item's prices when the request was made —
/// approval fails if the item has been repriced since (stale request).</summary>
public class PriceAdjustmentLine
{
public int PriceAdjustmentLineId { get; set; }
public int PriceAdjustmentId { get; set; }
public PriceAdjustment? PriceAdjustment { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal? OldMinPrice { get; set; }
public decimal? OldMaxPrice { get; set; }
public decimal? OldFlatPrice { get; set; }
public decimal? NewMinPrice { get; set; }
public decimal? NewMaxPrice { get; set; }
public decimal? NewFlatPrice { get; set; }
}
/// <summary>An applied selling-price change: what it was, what it became, and who approved it.</summary>
public class ItemPriceHistory
{
public int ItemPriceHistoryId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int? PriceAdjustmentId { get; set; }
public PriceAdjustment? PriceAdjustment { get; set; }
public decimal? OldMinPrice { get; set; }
public decimal? OldMaxPrice { get; set; }
public decimal? OldFlatPrice { get; set; }
public decimal? NewMinPrice { get; set; }
public decimal? NewMaxPrice { get; set; }
public decimal? NewFlatPrice { get; set; }
public int RequestedBy { get; set; }
public int? ApprovedLevel1By { get; set; }
public int? ApprovedLevel2By { get; set; }
public DateTime AppliedAt { get; set; }
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
@@ -21,6 +23,9 @@ public class SalesInvoicePayment
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary> /// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
public string BankAccountName { get; set; } = string.Empty; public string BankAccountName { get; set; } = string.Empty;
/// <summary>How the customer paid. Payments recorded before this existed were backfilled as Cash.</summary>
public SalesPaymentMethod PaymentMethod { get; set; } = SalesPaymentMethod.Cash;
public string? Reference { get; set; } public string? Reference { get; set; }
/// <summary>Journal number of the real GL journal entry this payment posted.</summary> /// <summary>Journal number of the real GL journal entry this payment posted.</summary>
@@ -27,6 +27,11 @@ public class SalesSlip
public decimal PaidAmount { get; set; } public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; } public decimal BalanceAmount { get; set; }
/// <summary>How <see cref="PaidAmount"/> was tendered: <c>CashAmount + CardAmount == PaidAmount</c>.
/// Slips from before the split existed were backfilled as all cash.</summary>
public decimal CashAmount { get; set; }
public decimal CardAmount { get; set; }
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary> /// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
public int? DayEndId { get; set; } public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; } public SalesDayEnd? DayEnd { get; set; }
@@ -37,5 +37,20 @@ public class StockAdjustment
public string? GlJournalNo { get; set; } public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; } public DateTime? GlPostedAt { get; set; }
// --- Approval (docs/DUAL_APPROVAL_PLAN.md §5). CreatedBy is the requester. ---
/// <summary>Approvals this request needs (0 = posted on create, 1 or 2), fixed when it is made.</summary>
public int RequiredApprovals { get; set; }
public int? ApprovedLevel1By { get; set; }
public DateTime? ApprovedLevel1At { get; set; }
public int? ApprovedLevel2By { get; set; }
public DateTime? ApprovedLevel2At { get; set; }
public int? RejectedBy { get; set; }
public DateTime? RejectedAt { get; set; }
public string? RejectReason { get; set; }
public DateTime? CancelledAt { get; set; }
/// <summary>When stock actually moved — the ledger and GL use this, not <see cref="CreatedAt"/>.</summary>
public DateTime? PostedAt { get; set; }
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>(); public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
} }
@@ -21,4 +21,12 @@ public class StockAdjustmentLine
public Serial? Serial { get; set; } public Serial? Serial { get; set; }
public decimal QtyDelta { get; set; } public decimal QtyDelta { get; set; }
/// <summary>
/// The serials named on the request, kept until it posts (JSON arrays): outbound serial ids
/// to write off, inbound serial numbers found. Re-validated at posting, because a pending
/// request moves nothing and the serials may have moved in the meantime.
/// </summary>
public string? RequestedSerialIds { get; set; }
public string? RequestedSerialNos { get; set; }
} }
@@ -1,13 +1,33 @@
namespace ERPCore.Domain.Enums; namespace ERPCore.Domain.Enums;
/// <summary> /// <summary>
/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so /// Stock-adjustment lifecycle (docs/DUAL_APPROVAL_PLAN.md §5.1). A new adjustment is a request
/// <see cref="PendingApproval"/> is reserved for the future threshold-approval /// (<see cref="PendingApproval"/>, then <see cref="PendingSecondApproval"/> when two approvals are
/// workflow (FR-STK-07). Stored as a string. /// required) and only moves stock when it becomes <see cref="Posted"/>. With approvals switched off
/// (<c>ApprovalConfig.StockAdjustmentLevels = 0</c>) it posts on create, as before. Stored as a string.
/// </summary> /// </summary>
public enum AdjustmentStatus public enum AdjustmentStatus
{ {
Draft, Draft,
PendingApproval, PendingApproval,
Posted Posted,
PendingSecondApproval,
Rejected,
Cancelled
}
/// <summary>Price-adjustment lifecycle — same request flow as <see cref="AdjustmentStatus"/>; <see cref="Approved"/> means applied.</summary>
public enum PriceAdjustmentStatus
{
PendingApproval,
PendingSecondApproval,
Approved,
Rejected,
Cancelled
}
public enum ApprovalDecision
{
Approved,
Rejected
} }
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>A deposit is posted as soon as it is saved; a wrong one is cancelled (GL reversed) and re-entered.</summary>
public enum BankDepositStatus
{
Posted,
Cancelled
}
@@ -0,0 +1,24 @@
namespace ERPCore.Domain.Enums;
public enum ExpenseStatus
{
Draft,
Posted,
Cancelled
}
/// <summary>Settlement state of a Posted expense, derived from its paid/balance amounts.</summary>
public enum ExpensePaymentStatus
{
Unpaid,
PartiallyPaid,
Paid
}
public enum ExpensePaymentMethod
{
Cash,
BankTransfer,
Cheque,
Card
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>How a customer paid for a sale — recorded per invoice payment (and split into
/// cash/card amounts on slips and bundle sales) so the Day-End Sales Report can break
/// collections down by method. Mirrors <see cref="ExpensePaymentMethod"/>.</summary>
public enum SalesPaymentMethod
{
Cash,
Card,
BankTransfer,
Cheque
}
@@ -27,6 +27,18 @@ public sealed class StockAdjustmentCreatedNotificationModule;
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)] [NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
public sealed class StockTransferCreatedNotificationModule; public sealed class StockTransferCreatedNotificationModule;
[NotificationModule("stock.low", "Low Stock / Reorder Alert", "Raised when a sale, transfer or adjustment takes an item's stock at a warehouse to or below its reorder point.", 95)]
public sealed class StockLowNotificationModule;
[NotificationModule("approval.pending", "Approval Needed", "Sent to the people who can approve a stock or price adjustment that is waiting for them.", 96)]
public sealed class ApprovalPendingNotificationModule;
[NotificationModule("approval.approved", "Request Approved", "Sent to the requester when their stock or price adjustment is fully approved and applied.", 97)]
public sealed class ApprovalApprovedNotificationModule;
[NotificationModule("approval.rejected", "Request Rejected", "Sent to the requester when their stock or price adjustment is rejected.", 98)]
public sealed class ApprovalRejectedNotificationModule;
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)] [NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
public sealed class ItemCreatedNotificationModule; public sealed class ItemCreatedNotificationModule;
@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
namespace ERPCore.Dtos.Accounts;
// --- Cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §7.1) ---
/// <summary>
/// One business day's till cash. CashIn is slip/bundle cash plus cash invoice payments received
/// that day; CashOut is expense payments made in cash; Deposited is Posted bank deposits for that
/// business day. Closing = Opening + NetCash − Deposited, and becomes the next day's Opening.
/// Days before the tracking start date carry nothing in: their Opening is 0.
/// </summary>
public sealed record CashPositionDto(
DateOnly Date, decimal Opening, decimal CashIn, decimal CashOut, decimal NetCash, decimal Deposited, decimal Closing);
/// <summary>
/// A day's cash position for the deposit form. <paramref name="AvailableToDeposit"/> is the lowest
/// closing balance from that day up to today: a back-dated deposit lowers every later day too.
/// <paramref name="DefaultCashAccountCode"/> is the GL cash account the form preselects.
/// </summary>
public sealed record CashInHandDto(
CashPositionDto Position, decimal AvailableToDeposit, int? BranchId, DateOnly? TrackingStartDate, string TimeZone,
string? DefaultCashAccountCode);
public sealed record CashInHandDailyDto(
DateOnly From, DateOnly To, IReadOnlyList<CashPositionDto> Days, int? BranchId, DateOnly? TrackingStartDate, string TimeZone);
// --- Deposits ---
public sealed record BankDepositDto(
int BankDepositId, string DocNo, int BranchId, string? BranchName,
DateOnly BusinessDate, DateOnly DepositDate, decimal Amount,
long FromGlCashAccountId, string FromCashAccountCode, string FromCashAccountName,
long ToGlBankAccountId, string ToBankAccountCode, string ToBankAccountName, string? ToBankName, string? ToAccountNumber,
string? DepositSlipNo, string? DepositedBy, string? Remarks,
BankDepositStatus Status, string? GlJournalNo, DateTime? GlPostedAt,
string? GlReversalJournalNo, string? CancelReason, DateTime? CancelledAt,
int CreatedBy, DateTime CreatedAt);
/// <summary>A page of deposits plus the total amount of every row matching the filter (not just this page).</summary>
public sealed record BankDepositListDto(IReadOnlyList<BankDepositDto> Items, PaginationDto Pagination, decimal TotalAmount);
public sealed record BankDepositListFilter(
DateOnly? From, DateOnly? To, int? BranchId, long? BankAccountId, BankDepositStatus? Status);
public sealed class CreateBankDepositRequest
{
/// <summary>Required when branching is enabled; otherwise the default branch is used. A branch user always deposits for their own branch.</summary>
public int BranchId { get; set; }
[Required] public DateOnly BusinessDate { get; set; }
/// <summary>When the bank received it; also the GL entry date. Omitted ⇒ the business date.</summary>
public DateOnly? DepositDate { get; set; }
[Range(0.01, 999_999_999_999)] public decimal Amount { get; set; }
[Required] public long FromGlCashAccountId { get; set; }
[Required] public long ToGlBankAccountId { get; set; }
[StringLength(50)] public string? DepositSlipNo { get; set; }
[StringLength(150)] public string? DepositedBy { get; set; }
[StringLength(500)] public string? Remarks { get; set; }
}
public sealed class CancelBankDepositRequest
{
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
}
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Approvals;
/// <summary>One decision in a document's approval trail.</summary>
public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int DecidedBy, string? DecidedByName, DateTime DecidedAt, string? Comment);
/// <summary>Settings → Approvals (docs/DUAL_APPROVAL_PLAN.md §7).</summary>
public sealed record ApprovalConfigDto(
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
IReadOnlyList<string> Level2RoleCodes, DateTime? UpdatedAt);
public sealed class UpdateApprovalConfigRequest
{
[Range(0, 2)] public int StockAdjustmentLevels { get; set; }
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
[Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
public List<string> Level2RoleCodes { get; set; } = new();
}
public sealed class ApproveRequest
{
[StringLength(250)] public string? Comment { get; set; }
}
public sealed class RejectRequest
{
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// A request waiting in the Approvals inbox. <paramref name="Level"/> is the approval it waits for
/// (1 or 2) of <paramref name="RequiredApprovals"/>; <paramref name="CanApprove"/> says whether the
/// current user may decide it (not the requester, not the level-1 approver, has the right role).
/// </summary>
public sealed record PendingApprovalDto(
string DocType, int DocId, string DocNo, string Summary, int Level, int RequiredApprovals,
int RequestedBy, string? RequestedByName, DateTime RequestedAt, bool CanApprove, string? WhyNot);
public sealed record PendingApprovalsDto(IReadOnlyList<PendingApprovalDto> Items, int ActionableCount);
@@ -4,6 +4,9 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Customers; namespace ERPCore.Dtos.Customers;
/// <summary>Customer resource used by sales documents.</summary> /// <summary>Customer resource used by sales documents.</summary>
/// <param name="OutstandingAmount">Unpaid balance across the customer's Posted sales invoices. Filled on the list only.</param>
/// <param name="OutstandingInvoiceCount">Posted sales invoices with a balance still due. Filled on the list only.</param>
/// <param name="LastPaymentDate">When the customer last paid against an invoice; null if never. Filled on the list only.</param>
public sealed record CustomerDto( public sealed record CustomerDto(
int CustomerId, int CustomerId,
string CustomerCode, string CustomerCode,
@@ -23,7 +26,10 @@ public sealed record CustomerDto(
EntityStatus Status, EntityStatus Status,
int BranchId, int BranchId,
DateTime CreatedAt, DateTime CreatedAt,
DateTime? UpdatedAt); DateTime? UpdatedAt,
decimal OutstandingAmount = 0m,
int OutstandingInvoiceCount = 0,
DateTime? LastPaymentDate = null);
public sealed class CreateCustomerRequest public sealed class CreateCustomerRequest
{ {
@@ -0,0 +1,120 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Expenses;
// --- Setup: categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup") ---
public sealed record ExpenseTitleDto(
int ExpenseTitleId, int ExpenseCategoryId, string Name, string GlExpenseAccountCode,
bool IsSystem, EntityStatus Status, int SortOrder);
public sealed record ExpenseCategoryDto(
int ExpenseCategoryId, string Code, string Name, bool IsSystem, EntityStatus Status, int SortOrder,
IReadOnlyList<ExpenseTitleDto> Titles);
public sealed class UpdateExpenseCategoryRequest
{
/// <summary>Ignored-if-unchanged for system rows; a different name on a system row is rejected.</summary>
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
}
public sealed class CreateExpenseTitleRequest
{
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
[Required, StringLength(20, MinimumLength = 1)] public string GlExpenseAccountCode { get; set; } = string.Empty;
}
public sealed class UpdateExpenseTitleRequest
{
/// <summary>Ignored-if-unchanged for system rows; a different name on a system row is rejected.</summary>
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
[Required, StringLength(20, MinimumLength = 1)] public string GlExpenseAccountCode { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
}
// --- Expenses ---
public sealed record ExpenseSummaryDto(
int ExpenseId, string DocNo, int BranchId, DateOnly ExpenseDate, DateOnly? DueDate,
int ExpenseCategoryId, string CategoryName, int ExpenseTitleId, string TitleName,
string? Payee, string? BillReference, string? PeriodMonth,
decimal Amount, decimal PaidAmount, decimal BalanceAmount,
ExpenseStatus Status, ExpensePaymentStatus PaymentStatus, string? GlJournalNo, DateTime CreatedAt);
public sealed record ExpensePaymentDto(
int ExpensePaymentId, int ExpenseId, decimal Amount, DateTime PaymentDate, ExpensePaymentMethod PaymentMethod,
long GlBankAccountId, string BankAccountName, string? ChequeNo, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed record ExpenseDto(
int ExpenseId, string DocNo, int BranchId, DateOnly ExpenseDate, DateOnly? DueDate,
int ExpenseCategoryId, string CategoryName, int ExpenseTitleId, string TitleName,
string? Payee, int? VendorId, string? VendorName, string? BillReference, string? Description, string? PeriodMonth,
decimal Amount, decimal PaidAmount, decimal BalanceAmount,
ExpenseStatus Status, ExpensePaymentStatus PaymentStatus,
string? GlExpenseAccountCode, string? GlJournalNo, DateTime? GlPostedAt, string? GlReversalJournalNo, string? CancelReason,
int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
IReadOnlyList<ExpensePaymentDto> Payments);
public class ExpenseRequestBase
{
/// <summary>Required when branching is enabled; otherwise the default branch is used.</summary>
public int BranchId { get; set; }
[Required] public DateOnly ExpenseDate { get; set; }
public DateOnly? DueDate { get; set; }
[Required] public int ExpenseCategoryId { get; set; }
[Required] public int ExpenseTitleId { get; set; }
[StringLength(150)] public string? Payee { get; set; }
public int? VendorId { get; set; }
[StringLength(50)] public string? BillReference { get; set; }
[StringLength(500)] public string? Description { get; set; }
[RegularExpression(@"^\d{4}-(0[1-9]|1[0-2])$", ErrorMessage = "PeriodMonth must be yyyy-MM.")]
public string? PeriodMonth { get; set; }
[Range(0.01, 999_999_999_999)] public decimal Amount { get; set; }
}
public sealed class CreateExpenseRequest : ExpenseRequestBase;
public sealed class UpdateExpenseRequest : ExpenseRequestBase;
public sealed class CancelExpenseRequest
{
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
}
public sealed class ExpensePaymentLineRequest
{
[Required] public long GlBankAccountId { get; set; }
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
}
/// <summary>
/// One payment submission: one or more lines (e.g. part cash, part bank), all posted in a
/// single GL journal entry. Total must not exceed the balance (for Post &amp; Pay, the amount); partial is fine.
/// </summary>
public sealed class CreateExpensePaymentRequest
{
[Required, MinLength(1)] public List<ExpensePaymentLineRequest> Lines { get; set; } = new();
public ExpensePaymentMethod PaymentMethod { get; set; } = ExpensePaymentMethod.Cash;
/// <summary>Required when <see cref="PaymentMethod"/> is Cheque.</summary>
[StringLength(30)] public string? ChequeNo { get; set; }
[StringLength(100)] public string? Reference { get; set; }
/// <summary>When it was paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
}
public sealed record ExpenseListFilter(
ExpenseStatus? Status, ExpensePaymentStatus? PaymentStatus, int? CategoryId, int? TitleId,
DateOnly? From, DateOnly? To, bool OverdueOnly);
public sealed record ExpenseTitleTotalDto(int ExpenseTitleId, string TitleName, decimal Amount, int Count);
public sealed record ExpenseCategoryTotalDto(
int ExpenseCategoryId, string CategoryName, decimal Amount, int Count, IReadOnlyList<ExpenseTitleTotalDto> Titles);
/// <summary>Posted (non-cancelled) expenses dated within [From, To], plus the all-time unpaid/overdue position.</summary>
public sealed record ExpenseSummaryReportDto(
DateOnly From, DateOnly To, decimal TotalAmount, decimal PaidAmount, decimal UnpaidAmount, int Count,
decimal OutstandingAmount, decimal OverdueAmount, int OverdueCount, int DraftCount,
IReadOnlyList<ExpenseCategoryTotalDto> Categories);
@@ -2,6 +2,14 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items; namespace ERPCore.Dtos.Items;
/// <summary>
/// One posted GRN receipt of an item — what it was bought at. <paramref name="NetUnitCost"/> is the
/// cost after trade discount (the FIFO stock cost, VAT excluded) — the figure to price against.
/// </summary>
public sealed record ItemPurchasePriceDto(
int GrnId, string GrnDocNo, DateTime ReceivedAt, int VendorId, string? VendorName, string? WarehouseCode,
decimal Qty, decimal UnitCost, decimal DiscountPct, decimal NetUnitCost, decimal VatPct);
/// <summary> /// <summary>
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock /// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a /// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
namespace ERPCore.Dtos.Products;
/// <summary>One item's requested change. Current* are the item's prices now — they differ from Old* when it was repriced since.</summary>
public sealed record PriceAdjustmentLineDto(
int PriceAdjustmentLineId, int ItemId, string Sku, string ItemName,
decimal? OldMinPrice, decimal? OldMaxPrice, decimal? OldFlatPrice,
decimal? NewMinPrice, decimal? NewMaxPrice, decimal? NewFlatPrice,
decimal? CurrentMinPrice, decimal? CurrentMaxPrice, decimal? CurrentFlatPrice);
public sealed record PriceAdjustmentSummaryDto(
int PriceAdjustmentId, string DocNo, string Reason, PriceAdjustmentStatus Status, int RequiredApprovals, int? PendingLevel,
int LineCount, int CreatedBy, string? CreatedByName, DateTime CreatedAt, DateTime? AppliedAt);
/// <param name="PendingLevel">The approval it waits for (1 or 2); null once decided.</param>
public sealed record PriceAdjustmentDto(
int PriceAdjustmentId, string DocNo, string Reason, PriceAdjustmentStatus Status, int RequiredApprovals, int? PendingLevel,
int CreatedBy, string? CreatedByName, DateTime CreatedAt,
int? ApprovedLevel1By, DateTime? ApprovedLevel1At, int? ApprovedLevel2By, DateTime? ApprovedLevel2At,
int? RejectedBy, DateTime? RejectedAt, string? RejectReason, DateTime? CancelledAt, DateTime? AppliedAt,
IReadOnlyList<PriceAdjustmentLineDto> Lines, IReadOnlyList<ApprovalStepDto> ApprovalHistory, bool CanApprove, string? WhyNot,
bool CanWithdraw);
public sealed class PriceAdjustmentLineInput
{
[Required] public int ItemId { get; set; }
/// <summary>The prices the item should have afterwards — null clears that price.</summary>
[Range(0, double.MaxValue)] public decimal? NewMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? NewMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? NewFlatPrice { get; set; }
}
public sealed class CreatePriceAdjustmentRequest
{
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
[Required, MinLength(1)] public List<PriceAdjustmentLineInput> Lines { get; set; } = new();
}
public sealed record ItemPriceHistoryDto(
int ItemPriceHistoryId, int ItemId, int? PriceAdjustmentId, string? DocNo,
decimal? OldMinPrice, decimal? OldMaxPrice, decimal? OldFlatPrice,
decimal? NewMinPrice, decimal? NewMaxPrice, decimal? NewFlatPrice,
int RequestedBy, string? RequestedByName, int? ApprovedLevel1By, string? ApprovedLevel1ByName,
int? ApprovedLevel2By, string? ApprovedLevel2ByName, DateTime AppliedAt);
@@ -0,0 +1,51 @@
namespace ERPCore.Dtos.Reports;
/// <summary>
/// One business day's figures (or, with <see cref="Date"/> null, a range's totals) — docs/DAY_END_SALES_REPORT_PLAN.md §4.
/// Sales are Posted slips, bundle sales and invoices dated that day; Paid is what has been paid on
/// those sales so far (split by how it was paid) and Due the rest.
/// </summary>
/// <param name="Income">TotalSales − ExpenseTotal (sales returns aren't priced, so they can't be netted off).</param>
/// <param name="CostOfGoodsSold">FIFO cost of the stock those sales issued (the stock ledger's Out value).</param>
/// <param name="GrossProfit">TotalSales − CostOfGoodsSold.</param>
/// <param name="NetProfit">GrossProfit − ExpenseTotal; negative is a loss.</param>
/// <param name="EarlierInvoiceCollections">Invoice payments received this day against invoices from
/// earlier days — not part of PaidAmount.</param>
/// <param name="NetCash">Cash that came in this day (slip/bundle cash + cash invoice payments, any
/// invoice) minus expenses paid in cash this day — a till check.</param>
/// <param name="OpeningCashInHand">Cash left in the till from earlier days (docs/BANK_DEPOSIT_MODULE_PLAN.md §9).
/// For a range's totals, the first day's opening.</param>
/// <param name="CashDeposited">Posted bank deposits made against this business day.</param>
/// <param name="ClosingCashInHand">Opening + NetCash − CashDeposited: cash still in hand. For a range's totals, the last day's closing.</param>
public sealed record DayEndReportDayDto(
DateOnly? Date,
decimal TotalSales, decimal PaidAmount, decimal DueAmount,
decimal PaidByCash, decimal PaidByCard, decimal PaidByOther,
int SalesCount, int CashSalesCount, int CardSalesCount, int MixedSalesCount, int OtherSalesCount, int UnpaidSalesCount,
decimal ExpenseTotal, decimal Income,
decimal CostOfGoodsSold, decimal GrossProfit, decimal NetProfit,
decimal EarlierInvoiceCollections,
decimal NetCash,
decimal OpeningCashInHand, decimal CashDeposited, decimal ClosingCashInHand);
/// <summary>One sale of the day. <paramref name="PaidBy"/> is Cash, Card, Bank transfer, Cheque, Mixed or Unpaid.</summary>
public sealed record DayEndReportSaleDto(
string DocType, int DocId, string DocNo, DateTime At, string CustomerName,
decimal Total, decimal Paid, decimal Due, string PaidBy, decimal Cost, decimal GrossProfit);
public sealed record DayEndReportExpenseDto(
int ExpenseId, string DocNo, string CategoryName, string TitleName, string? Payee, decimal Amount, decimal PaidAmount);
/// <summary>A Posted bank deposit of this business day's cash.</summary>
public sealed record DayEndReportDepositDto(
int BankDepositId, string DocNo, string BankAccountName, string? BankName, string? AccountNumber,
string? DepositSlipNo, DateOnly DepositDate, decimal Amount);
/// <summary>A single day: its figures plus the sales, expenses and bank deposits behind them.</summary>
public sealed record DayEndReportDto(
DayEndReportDayDto Summary, IReadOnlyList<DayEndReportSaleDto> Sales, IReadOnlyList<DayEndReportExpenseDto> Expenses,
IReadOnlyList<DayEndReportDepositDto> Deposits, int? BranchId, string TimeZone);
/// <summary>Day-wise figures for a range (every day present, zeros on quiet days) plus the range's totals.</summary>
public sealed record DayEndDailyReportDto(
DateOnly From, DateOnly To, IReadOnlyList<DayEndReportDayDto> Days, DayEndReportDayDto Totals, int? BranchId, string TimeZone);
+5 -1
View File
@@ -12,7 +12,7 @@ public sealed record BundleSaleDto(
int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode, int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode,
BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount, BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount,
decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt,
IReadOnlyList<BundleSaleLineDto> Lines); IReadOnlyList<BundleSaleLineDto> Lines, decimal CashAmount, decimal CardAmount);
public sealed record BundleSaleSummaryDto( public sealed record BundleSaleSummaryDto(
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName, int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
@@ -66,6 +66,8 @@ public sealed class CreateBundleSaleRequest
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; } [Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
public bool AllowPriceOverride { get; set; } public bool AllowPriceOverride { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
/// <summary>The part of the total tendered by card; the rest is cash. Omit for all cash.</summary>
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
} }
public sealed class UpdateBundleSaleRequest public sealed class UpdateBundleSaleRequest
@@ -78,4 +80,6 @@ public sealed class UpdateBundleSaleRequest
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; } [Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
public bool AllowPriceOverride { get; set; } public bool AllowPriceOverride { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
/// <summary>See <see cref="CreateBundleSaleRequest.CardAmount"/>.</summary>
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
} }
@@ -31,12 +31,15 @@ public sealed record SalesInvoiceSummaryDto(
public sealed record SalesInvoicePaymentDto( public sealed record SalesInvoicePaymentDto(
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate, int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt); long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt,
SalesPaymentMethod PaymentMethod);
public sealed class CreateSalesInvoicePaymentRequest public sealed class CreateSalesInvoicePaymentRequest
{ {
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; } [Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; } [Required] public long GlBankAccountId { get; set; }
/// <summary>How the customer paid. Omitted ⇒ derived from the account: a Cash account ⇒ Cash, a Bank account ⇒ Card.</summary>
public SalesPaymentMethod? PaymentMethod { get; set; }
[StringLength(100)] public string? Reference { get; set; } [StringLength(100)] public string? Reference { get; set; }
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary> /// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; } public DateOnly? PaymentDate { get; set; }
@@ -47,6 +50,8 @@ public sealed class BulkSalesInvoicePaymentLine
{ {
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; } [Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; } [Required] public long GlBankAccountId { get; set; }
/// <summary>See <see cref="CreateSalesInvoicePaymentRequest.PaymentMethod"/>.</summary>
public SalesPaymentMethod? PaymentMethod { get; set; }
} }
/// <summary> /// <summary>
+8 -1
View File
@@ -12,7 +12,7 @@ public sealed record SalesSlipLineDto(
public sealed record SalesSlipTotalsDto( public sealed record SalesSlipTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
decimal PaidAmount, decimal BalanceAmount); decimal PaidAmount, decimal BalanceAmount, decimal CashAmount, decimal CardAmount);
public sealed record SalesSlipDto( public sealed record SalesSlipDto(
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId, int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
@@ -87,6 +87,10 @@ public sealed class CreateSalesSlipRequest
/// Receivable when it posts (see SalesDayEndService.CloseAsync). /// Receivable when it posts (see SalesDayEndService.CloseAsync).
/// </summary> /// </summary>
[Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; } [Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
/// <summary>The part of the paid amount tendered by card; the rest of it is cash. Omit for all
/// cash. Must be between 0 and the paid amount.</summary>
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
} }
public sealed class UpdateSalesSlipRequest public sealed class UpdateSalesSlipRequest
@@ -98,4 +102,7 @@ public sealed class UpdateSalesSlipRequest
/// <summary>See <see cref="CreateSalesSlipRequest.PaidAmount"/>.</summary> /// <summary>See <see cref="CreateSalesSlipRequest.PaidAmount"/>.</summary>
[Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; } [Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
/// <summary>See <see cref="CreateSalesSlipRequest.CardAmount"/>.</summary>
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
} }
+15 -3
View File
@@ -5,17 +5,29 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.5) ------------------------------------------------------ // Responses (docs/11 §5.5) ------------------------------------------------------
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId); /// <param name="RequestedSerialIds">Serials named to write off (shown while the request is pending).</param>
/// <param name="RequestedSerialNos">Serial numbers named as found (shown while the request is pending).</param>
public sealed record AdjustmentLineDto(
int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId,
IReadOnlyList<int>? RequestedSerialIds = null, IReadOnlyList<string>? RequestedSerialNos = null);
/// <param name="PendingLevel">The approval it waits for (1 or 2); null once decided or posted.</param>
/// <param name="CanApprove">Whether the current user may give that approval; <paramref name="WhyNot"/> says why not.</param>
public sealed record AdjustmentDto( public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status, int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt, int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs); IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs,
int RequiredApprovals = 0, int? PendingLevel = null,
int? ApprovedLevel1By = null, DateTime? ApprovedLevel1At = null, int? ApprovedLevel2By = null, DateTime? ApprovedLevel2At = null,
int? RejectedBy = null, DateTime? RejectedAt = null, string? RejectReason = null, DateTime? PostedAt = null,
IReadOnlyList<Approvals.ApprovalStepDto>? ApprovalHistory = null, bool CanApprove = false, string? WhyNot = null,
bool CanWithdraw = false, string? CreatedByName = null);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary> /// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto( public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status, int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo); int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo,
int RequiredApprovals = 0, int? PendingLevel = null);
// Requests ---------------------------------------------------------------------- // Requests ----------------------------------------------------------------------
+4 -1
View File
@@ -18,7 +18,10 @@ public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status, int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount); int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs); /// <param name="AdjustmentStatus">The variance adjustment's status — <c>PendingApproval</c> when it must be approved before stock moves.</param>
public sealed record CountPostResultDto(
int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs,
AdjustmentStatus? AdjustmentStatus = null, string? AdjustmentDocNo = null);
// Requests ---------------------------------------------------------------------- // Requests ----------------------------------------------------------------------
@@ -0,0 +1,34 @@
using ERPCore.Services.Stock;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ERPCore.Infra.Filters;
/// <summary>
/// After every API action that succeeded, checks whether its stock issues pushed anything to or
/// below its reorder point (<see cref="LowStockAlertService"/>). By then the action's UoW
/// transaction has committed, so only stock that really left is alerted on. The check is
/// best-effort: a failure is logged and never fails the request that moved the stock.
/// </summary>
public sealed class LowStockAlertFilter(LowStockAlertService alerts, LowStockTracker tracker, ILogger<LowStockAlertFilter> logger) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var executed = await next();
// A failed action rolled its transaction back: nothing it "issued" actually left stock.
if (executed.Exception is not null && !executed.ExceptionHandled)
{
tracker.Drain();
return;
}
try
{
await alerts.CheckAsync(context.HttpContext.RequestAborted);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Low-stock check failed after {Path}", context.HttpContext.Request.Path);
}
}
}
@@ -0,0 +1,99 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Dual approval tables (docs/DUAL_APPROVAL_PLAN.md §4, §6, §7).</summary>
public sealed class DocumentApprovalConfiguration : IEntityTypeConfiguration<DocumentApproval>
{
public void Configure(EntityTypeBuilder<DocumentApproval> builder)
{
builder.ToTable("document_approvals");
builder.HasKey(a => a.DocumentApprovalId);
builder.Property(a => a.DocType).IsRequired().HasMaxLength(10);
builder.Property(a => a.Decision).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(a => a.Comment).HasMaxLength(250);
builder.Property(a => a.DecidedAt).IsRequired();
builder.HasOne(a => a.Decider).WithMany().HasForeignKey(a => a.DecidedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => new { a.DocType, a.DocId });
}
}
public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<ApprovalConfig>
{
public void Configure(EntityTypeBuilder<ApprovalConfig> builder)
{
builder.ToTable("approval_config", t =>
t.HasCheckConstraint("ck_approval_config_singleton", $"`ApprovalConfigId` = {ApprovalConfig.SingletonId}"));
builder.HasKey(c => c.ApprovalConfigId);
builder.Property(c => c.ApprovalConfigId).ValueGeneratedNever();
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500);
// Dual approval on by default for both documents (plan §8 recommended defaults).
builder.HasData(new ApprovalConfig
{
ApprovalConfigId = ApprovalConfig.SingletonId,
StockAdjustmentLevels = 2,
PriceAdjustmentLevels = 2,
RowVersion = 1
});
}
}
public sealed class PriceAdjustmentConfiguration : IEntityTypeConfiguration<PriceAdjustment>
{
public void Configure(EntityTypeBuilder<PriceAdjustment> builder)
{
builder.ToTable("price_adjustments");
builder.HasKey(p => p.PriceAdjustmentId);
builder.Property(p => p.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(p => p.DocNo).IsUnique();
builder.Property(p => p.Reason).IsRequired().HasMaxLength(250);
builder.Property(p => p.Status).HasConversion<string>().HasMaxLength(30).IsRequired()
.HasDefaultValue(PriceAdjustmentStatus.PendingApproval);
builder.Property(p => p.RejectReason).HasMaxLength(250);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(p => p.Lines).WithOne(l => l.PriceAdjustment).HasForeignKey(l => l.PriceAdjustmentId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(p => p.Status);
}
}
public sealed class PriceAdjustmentLineConfiguration : IEntityTypeConfiguration<PriceAdjustmentLine>
{
public void Configure(EntityTypeBuilder<PriceAdjustmentLine> builder)
{
builder.ToTable("price_adjustment_lines");
builder.HasKey(l => l.PriceAdjustmentLineId);
foreach (var p in new[] { nameof(PriceAdjustmentLine.OldMinPrice), nameof(PriceAdjustmentLine.OldMaxPrice), nameof(PriceAdjustmentLine.OldFlatPrice),
nameof(PriceAdjustmentLine.NewMinPrice), nameof(PriceAdjustmentLine.NewMaxPrice), nameof(PriceAdjustmentLine.NewFlatPrice) })
builder.Property<decimal?>(p).HasPrecision(18, 4);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(l => l.ItemId);
}
}
public sealed class ItemPriceHistoryConfiguration : IEntityTypeConfiguration<ItemPriceHistory>
{
public void Configure(EntityTypeBuilder<ItemPriceHistory> builder)
{
builder.ToTable("item_price_history");
builder.HasKey(h => h.ItemPriceHistoryId);
foreach (var p in new[] { nameof(ItemPriceHistory.OldMinPrice), nameof(ItemPriceHistory.OldMaxPrice), nameof(ItemPriceHistory.OldFlatPrice),
nameof(ItemPriceHistory.NewMinPrice), nameof(ItemPriceHistory.NewMaxPrice), nameof(ItemPriceHistory.NewFlatPrice) })
builder.Property<decimal?>(p).HasPrecision(18, 4);
builder.Property(h => h.AppliedAt).IsRequired();
builder.HasOne(h => h.Item).WithMany().HasForeignKey(h => h.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(h => h.PriceAdjustment).WithMany().HasForeignKey(h => h.PriceAdjustmentId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(h => new { h.ItemId, h.AppliedAt });
}
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md §4).</summary>
public sealed class BankDepositConfiguration : IEntityTypeConfiguration<BankDeposit>
{
public void Configure(EntityTypeBuilder<BankDeposit> builder)
{
builder.ToTable("bank_deposits", t =>
t.HasCheckConstraint("ck_bank_deposits_amount", "`Amount` > 0"));
builder.HasKey(x => x.BankDepositId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.BusinessDate).IsRequired();
builder.Property(x => x.DepositDate).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 4);
builder.Property(x => x.FromCashAccountCode).IsRequired().HasMaxLength(20);
builder.Property(x => x.FromCashAccountName).IsRequired().HasMaxLength(200);
builder.Property(x => x.ToBankAccountCode).IsRequired().HasMaxLength(20);
builder.Property(x => x.ToBankAccountName).IsRequired().HasMaxLength(200);
builder.Property(x => x.ToBankName).HasMaxLength(150);
builder.Property(x => x.ToAccountNumber).HasMaxLength(50);
builder.Property(x => x.DepositSlipNo).HasMaxLength(50);
builder.Property(x => x.DepositedBy).HasMaxLength(150);
builder.Property(x => x.Remarks).HasMaxLength(500);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(BankDepositStatus.Posted);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.GlReversalJournalNo).HasMaxLength(30);
builder.Property(x => x.CancelReason).HasMaxLength(250);
builder.Property(x => x.CreatedAt).IsRequired();
builder.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.BranchId, x.BusinessDate, x.Status });
builder.HasIndex(x => x.ToGlBankAccountId);
}
}
@@ -26,6 +26,8 @@ public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSal
builder.Property(x => x.DiscountTotal).HasPrecision(18, 4); builder.Property(x => x.DiscountTotal).HasPrecision(18, 4);
builder.Property(x => x.TaxTotal).HasPrecision(18, 4); builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
builder.Property(x => x.GrandTotal).HasPrecision(18, 4); builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
builder.Property(x => x.CashAmount).HasPrecision(18, 4);
builder.Property(x => x.CardAmount).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired(); builder.Property(x => x.CreatedAt).IsRequired();
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
@@ -0,0 +1,170 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Expense module tables (docs/EXPENSE_MODULE_PLAN.md §3). The four categories and their
/// titles are seeded as <c>IsSystem</c> rows. The GL codes are the children of the GL
/// "Operating Expenses" (5200) control account created for this module on 2026-10-08 — GL
/// generates codes itself, so a GL instance with a different chart needs these corrected per
/// title on the Expense Setup screen.
/// </summary>
public sealed class ExpenseCategoryConfiguration : IEntityTypeConfiguration<ExpenseCategory>
{
public const string UtilityGlCode = "5200-01";
public const string RentalGlCode = "5200-02";
public const string SalariesGlCode = "5200-03";
public const string OtherGlCode = "5200-04";
public void Configure(EntityTypeBuilder<ExpenseCategory> builder)
{
builder.ToTable("expense_categories");
builder.HasKey(c => c.ExpenseCategoryId);
builder.Property(c => c.Code).IsRequired().HasMaxLength(30);
builder.HasIndex(c => c.Code).IsUnique();
builder.Property(c => c.Name).IsRequired().HasMaxLength(100);
builder.HasIndex(c => c.Name).IsUnique();
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.HasMany(c => c.Titles)
.WithOne(t => t.Category)
.HasForeignKey(t => t.ExpenseCategoryId)
.OnDelete(DeleteBehavior.Restrict);
var seededAt = new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc);
builder.HasData(
new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility", IsSystem = true, SortOrder = 1, CreatedAt = seededAt, RowVersion = 1 },
new ExpenseCategory { ExpenseCategoryId = 2, Code = "RENTAL", Name = "Rental", IsSystem = true, SortOrder = 2, CreatedAt = seededAt, RowVersion = 1 },
new ExpenseCategory { ExpenseCategoryId = 3, Code = "SALARIES", Name = "Salaries", IsSystem = true, SortOrder = 3, CreatedAt = seededAt, RowVersion = 1 },
new ExpenseCategory { ExpenseCategoryId = 4, Code = "OTHER", Name = "Other", IsSystem = true, SortOrder = 4, CreatedAt = seededAt, RowVersion = 1 }
);
}
}
public sealed class ExpenseTitleConfiguration : IEntityTypeConfiguration<ExpenseTitle>
{
public void Configure(EntityTypeBuilder<ExpenseTitle> builder)
{
builder.ToTable("expense_titles");
builder.HasKey(t => t.ExpenseTitleId);
builder.Property(t => t.Name).IsRequired().HasMaxLength(100);
builder.HasIndex(t => new { t.ExpenseCategoryId, t.Name }).IsUnique();
builder.Property(t => t.GlExpenseAccountCode).IsRequired().HasMaxLength(20);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
var seededAt = new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc);
var id = 0;
ExpenseTitle Seed(int categoryId, string name, string glCode, int sortOrder) => new()
{
ExpenseTitleId = ++id, ExpenseCategoryId = categoryId, Name = name, GlExpenseAccountCode = glCode,
IsSystem = true, SortOrder = sortOrder, CreatedAt = seededAt, RowVersion = 1
};
// Append only — the ids are assigned in this order, so reordering would renumber seeded rows.
builder.HasData(
Seed(1, "Electricity", ExpenseCategoryConfiguration.UtilityGlCode, 1),
Seed(1, "Water", ExpenseCategoryConfiguration.UtilityGlCode, 2),
Seed(1, "Telephone", ExpenseCategoryConfiguration.UtilityGlCode, 3),
Seed(1, "Internet", ExpenseCategoryConfiguration.UtilityGlCode, 4),
Seed(1, "Gas", ExpenseCategoryConfiguration.UtilityGlCode, 5),
Seed(2, "Building Rent", ExpenseCategoryConfiguration.RentalGlCode, 1),
Seed(2, "Vehicle Rent", ExpenseCategoryConfiguration.RentalGlCode, 2),
Seed(2, "Equipment Rent", ExpenseCategoryConfiguration.RentalGlCode, 3),
Seed(3, "Staff Salaries", ExpenseCategoryConfiguration.SalariesGlCode, 1),
Seed(3, "Wages", ExpenseCategoryConfiguration.SalariesGlCode, 2),
Seed(3, "Overtime", ExpenseCategoryConfiguration.SalariesGlCode, 3),
Seed(3, "Bonus", ExpenseCategoryConfiguration.SalariesGlCode, 4),
Seed(3, "EPF / ETF Contribution", ExpenseCategoryConfiguration.SalariesGlCode, 5),
Seed(4, "Stationery", ExpenseCategoryConfiguration.OtherGlCode, 1),
Seed(4, "Transport", ExpenseCategoryConfiguration.OtherGlCode, 2),
Seed(4, "Repairs & Maintenance", ExpenseCategoryConfiguration.OtherGlCode, 3),
Seed(4, "Cleaning", ExpenseCategoryConfiguration.OtherGlCode, 4),
Seed(4, "Miscellaneous", ExpenseCategoryConfiguration.OtherGlCode, 5)
);
}
}
public sealed class ExpenseConfiguration : IEntityTypeConfiguration<Expense>
{
public void Configure(EntityTypeBuilder<Expense> builder)
{
builder.ToTable("expenses", t =>
t.HasCheckConstraint("ck_expenses_amount", "`Amount` > 0"));
builder.HasKey(x => x.ExpenseId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.ExpenseDate).IsRequired();
builder.Property(x => x.Payee).HasMaxLength(150);
builder.Property(x => x.BillReference).HasMaxLength(50);
builder.Property(x => x.Description).HasMaxLength(500);
builder.Property(x => x.PeriodMonth).HasMaxLength(7);
foreach (var p in new[] { nameof(Expense.Amount), nameof(Expense.PaidAmount), nameof(Expense.BalanceAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(ExpenseStatus.Draft);
builder.Property(x => x.PaymentStatus)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(ExpensePaymentStatus.Unpaid);
builder.Property(x => x.GlExpenseAccountCode).HasMaxLength(20);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.GlReversalJournalNo).HasMaxLength(30);
builder.Property(x => x.CancelReason).HasMaxLength(250);
builder.Property(x => x.CreatedAt).IsRequired();
builder.HasOne(x => x.Category).WithMany().HasForeignKey(x => x.ExpenseCategoryId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Title).WithMany().HasForeignKey(x => x.ExpenseTitleId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Vendor).WithMany().HasForeignKey(x => x.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.Payments)
.WithOne(p => p.Expense)
.HasForeignKey(p => p.ExpenseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.PaymentStatus);
builder.HasIndex(x => x.ExpenseDate);
builder.HasIndex(x => x.BranchId);
}
}
public sealed class ExpensePaymentConfiguration : IEntityTypeConfiguration<ExpensePayment>
{
public void Configure(EntityTypeBuilder<ExpensePayment> builder)
{
builder.ToTable("expense_payments");
builder.HasKey(p => p.ExpensePaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.PaymentMethod).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.ChequeNo).HasMaxLength(30);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.ExpenseId);
}
}
@@ -84,7 +84,13 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 }, new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 }, new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }, new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 },
new Permission { PermissionId = 53, Code = "NAV:products.tax-rates", SubNavItemId = 43 } new Permission { PermissionId = 53, Code = "NAV:products.tax-rates", SubNavItemId = 43 },
new Permission { PermissionId = 54, Code = "NAV:accounts.expenses", SubNavItemId = 44 },
new Permission { PermissionId = 55, Code = "NAV:accounts.expense-setup", SubNavItemId = 45 },
new Permission { PermissionId = 56, Code = "NAV:sales.day-end-report", SubNavItemId = 46 },
new Permission { PermissionId = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 },
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments", SubNavItemId = 48 },
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 }
); );
} }
} }
@@ -70,6 +70,9 @@ public sealed class SalesInvoicePaymentConfiguration : IEntityTypeConfiguration<
builder.Property(p => p.Amount).HasPrecision(18, 4); builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired(); builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200); builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.PaymentMethod)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesPaymentMethod.Cash);
builder.Property(p => p.Reference).HasMaxLength(100); builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30); builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired(); builder.Property(p => p.CreatedAt).IsRequired();
@@ -38,7 +38,7 @@ public sealed class SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
.HasConversion<string>().HasMaxLength(20).IsRequired() .HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesSlipStatus.Draft); .HasDefaultValue(SalesSlipStatus.Draft);
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) }) foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount), nameof(SalesSlip.CashAmount), nameof(SalesSlip.CardAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4); builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired(); builder.Property(x => x.CreatedAt).IsRequired();
@@ -14,9 +14,12 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30); builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(a => a.DocNo).IsUnique(); builder.HasIndex(a => a.DocNo).IsUnique();
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired(); // 30, not 20: "PendingSecondApproval" is 21 characters.
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(30).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired(); builder.Property(a => a.CreatedAt).IsRequired();
builder.Property(a => a.GlJournalNo).HasMaxLength(30); builder.Property(a => a.GlJournalNo).HasMaxLength(30);
builder.Property(a => a.RejectReason).HasMaxLength(250);
builder.HasIndex(a => a.Status);
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
@@ -35,6 +38,8 @@ public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<
builder.HasKey(l => l.AdjLineId); builder.HasKey(l => l.AdjLineId);
builder.Property(l => l.QtyDelta).HasPrecision(18, 4); builder.Property(l => l.QtyDelta).HasPrecision(18, 4);
builder.Property(l => l.RequestedSerialIds).HasMaxLength(4000);
builder.Property(l => l.RequestedSerialNos).HasMaxLength(4000);
builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
@@ -32,17 +32,20 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 }, new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 }, new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 43, NavItemId = 2, Code = "products.tax-rates", Label = "Tax Rates", Href = "/dashboard/products/tax-rates", SortOrder = 7 }, new SubNavItem { SubNavItemId = 43, NavItemId = 2, Code = "products.tax-rates", Label = "Tax Rates", Href = "/dashboard/products/tax-rates", SortOrder = 7 },
new SubNavItem { SubNavItemId = 48, NavItemId = 2, Code = "products.price-adjustments", Label = "Price Adjustments", Href = "/dashboard/products/price-adjustments", SortOrder = 8 },
new SubNavItem { SubNavItemId = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 }, new SubNavItem { SubNavItemId = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 }, new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 }, new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 }, new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 49, NavItemId = 9, Code = "settings.approvals", Label = "Approvals", Href = "/dashboard/settings/approvals", SortOrder = 4 },
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId // Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than // 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
// delete+recreate, so any role already granted this permission keeps it; the rest are new. // delete+recreate, so any role already granted this permission keeps it; the rest are new.
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 }, new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 }, new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 }, new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 }, new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Approved Invoices", Href = "/dashboard/sales/day-end", SortOrder = 4 },
new SubNavItem { SubNavItemId = 46, NavItemId = 13, Code = "sales.day-end-report", Label = "Day-End Report", Href = "/dashboard/sales/day-end-report", SortOrder = 5 },
// Procurement (NavItemId 4) children — mirror the hub page order. // Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below; // IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now. // these procurement rows were never actually migrated into the database before now.
@@ -67,6 +70,9 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 }, new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 }, new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }, new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
new SubNavItem { SubNavItemId = 44, NavItemId = 12, Code = "accounts.expenses", Label = "Expenses", Href = "/dashboard/accounts/expenses", SortOrder = 4 },
new SubNavItem { SubNavItemId = 45, NavItemId = 12, Code = "accounts.expense-setup", Label = "Expense Setup", Href = "/dashboard/accounts/expense-setup", SortOrder = 5 },
new SubNavItem { SubNavItemId = 47, NavItemId = 12, Code = "accounts.bank-deposits", Label = "Bank Deposits", Href = "/dashboard/accounts/bank-deposits", SortOrder = 6 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx). // Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was // ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
// reused by mistake in migration 20260820100345, which upserts row 24 to become the // reused by mistake in migration 20260820100345, which upserts row 24 to become the
@@ -90,6 +90,22 @@ public class ErpDbContext : DbContext
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>(); public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>(); public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
// --- Accounts: expenses (docs/EXPENSE_MODULE_PLAN.md) ---
public DbSet<ExpenseCategory> ExpenseCategories => Set<ExpenseCategory>();
public DbSet<ExpenseTitle> ExpenseTitles => Set<ExpenseTitle>();
public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<ExpensePayment> ExpensePayments => Set<ExpensePayment>();
// --- Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md) ---
public DbSet<BankDeposit> BankDeposits => Set<BankDeposit>();
// --- Dual approval: stock & price adjustments (docs/DUAL_APPROVAL_PLAN.md) ---
public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>();
public DbSet<ApprovalConfig> ApprovalConfig => Set<ApprovalConfig>();
public DbSet<PriceAdjustment> PriceAdjustments => Set<PriceAdjustment>();
public DbSet<PriceAdjustmentLine> PriceAdjustmentLines => Set<PriceAdjustmentLine>();
public DbSet<ItemPriceHistory> ItemPriceHistory => Set<ItemPriceHistory>();
// --- Batch / Serial (docs/10 Part C.4) --- // --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>(); public DbSet<Batch> Batches => Set<Batch>();
public DbSet<Serial> Serials => Set<Serial>(); public DbSet<Serial> Serials => Set<Serial>();
@@ -221,6 +237,8 @@ public class ErpDbContext : DbContext
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Expense>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<BankDeposit>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Optimistic concurrency token. On PostgreSQL this was the virtual `xmin` system // Optimistic concurrency token. On PostgreSQL this was the virtual `xmin` system
// column via .IsRowVersion(), which the engine maintained for free. MariaDB has no // column via .IsRowVersion(), which the engine maintained for free. MariaDB has no
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,336 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddExpenseModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "expense_categories",
columns: table => new
{
ExpenseCategoryId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
IsSystem = table.Column<bool>(type: "tinyint(1)", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Active", collation: "utf8mb4_unicode_ci"),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_expense_categories", x => x.ExpenseCategoryId);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "expense_titles",
columns: table => new
{
ExpenseTitleId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ExpenseCategoryId = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
GlExpenseAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
IsSystem = table.Column<bool>(type: "tinyint(1)", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Active", collation: "utf8mb4_unicode_ci"),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_expense_titles", x => x.ExpenseTitleId);
table.ForeignKey(
name: "FK_expense_titles_expense_categories_ExpenseCategoryId",
column: x => x.ExpenseCategoryId,
principalTable: "expense_categories",
principalColumn: "ExpenseCategoryId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "expenses",
columns: table => new
{
ExpenseId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DocNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
BranchId = table.Column<int>(type: "int", nullable: false),
ExpenseDate = table.Column<DateOnly>(type: "date", nullable: false),
DueDate = table.Column<DateOnly>(type: "date", nullable: true),
ExpenseCategoryId = table.Column<int>(type: "int", nullable: false),
ExpenseTitleId = table.Column<int>(type: "int", nullable: false),
Payee = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true, collation: "utf8mb4_unicode_ci"),
VendorId = table.Column<int>(type: "int", nullable: true),
BillReference = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true, collation: "utf8mb4_unicode_ci"),
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true, collation: "utf8mb4_unicode_ci"),
PeriodMonth = table.Column<string>(type: "varchar(7)", maxLength: 7, nullable: true, collation: "utf8mb4_unicode_ci"),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
PaidAmount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
BalanceAmount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Draft", collation: "utf8mb4_unicode_ci"),
PaymentStatus = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Unpaid", collation: "utf8mb4_unicode_ci"),
GlExpenseAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
GlPostedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
GlReversalJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
CancelReason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci"),
CreatedBy = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_expenses", x => x.ExpenseId);
table.CheckConstraint("ck_expenses_amount", "`Amount` > 0");
table.ForeignKey(
name: "FK_expenses_expense_categories_ExpenseCategoryId",
column: x => x.ExpenseCategoryId,
principalTable: "expense_categories",
principalColumn: "ExpenseCategoryId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_expenses_expense_titles_ExpenseTitleId",
column: x => x.ExpenseTitleId,
principalTable: "expense_titles",
principalColumn: "ExpenseTitleId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_expenses_hr_branches_BranchId",
column: x => x.BranchId,
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_expenses_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_expenses_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "expense_payments",
columns: table => new
{
ExpensePaymentId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ExpenseId = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
PaymentMethod = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
ChequeNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
Reference = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true, collation: "utf8mb4_unicode_ci"),
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
CreatedBy = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_expense_payments", x => x.ExpensePaymentId);
table.ForeignKey(
name: "FK_expense_payments_expenses_ExpenseId",
column: x => x.ExpenseId,
principalTable: "expenses",
principalColumn: "ExpenseId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_expense_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.InsertData(
table: "expense_categories",
columns: new[] { "ExpenseCategoryId", "Code", "CreatedAt", "IsSystem", "Name", "RowVersion", "SortOrder", "UpdatedAt" },
values: new object[,]
{
{ 1, "UTILITY", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Utility", 1u, 1, null },
{ 2, "RENTAL", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Rental", 1u, 2, null },
{ 3, "SALARIES", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Salaries", 1u, 3, null },
{ 4, "OTHER", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Other", 1u, 4, null }
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 44, "accounts.expenses", "/dashboard/accounts/expenses", null, "Expenses", 12, 4 },
{ 45, "accounts.expense-setup", "/dashboard/accounts/expense-setup", null, "Expense Setup", 12, 5 }
});
migrationBuilder.InsertData(
table: "expense_titles",
columns: new[] { "ExpenseTitleId", "CreatedAt", "ExpenseCategoryId", "GlExpenseAccountCode", "IsSystem", "Name", "RowVersion", "SortOrder", "UpdatedAt" },
values: new object[,]
{
{ 1, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Electricity", 1u, 1, null },
{ 2, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Water", 1u, 2, null },
{ 3, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Telephone", 1u, 3, null },
{ 4, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Internet", 1u, 4, null },
{ 5, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Gas", 1u, 5, null },
{ 6, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 2, "6200", true, "Building Rent", 1u, 1, null },
{ 7, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 2, "6200", true, "Vehicle Rent", 1u, 2, null },
{ 8, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 2, "6200", true, "Equipment Rent", 1u, 3, null },
{ 9, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Staff Salaries", 1u, 1, null },
{ 10, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Wages", 1u, 2, null },
{ 11, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Overtime", 1u, 3, null },
{ 12, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Bonus", 1u, 4, null },
{ 13, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "EPF / ETF Contribution", 1u, 5, null },
{ 14, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Stationery", 1u, 1, null },
{ 15, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Transport", 1u, 2, null },
{ 16, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Repairs & Maintenance", 1u, 3, null },
{ 17, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Cleaning", 1u, 4, null },
{ 18, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Miscellaneous", 1u, 5, null }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 54, "NAV:accounts.expenses", null, 44 },
{ 55, "NAV:accounts.expense-setup", null, 45 }
});
migrationBuilder.CreateIndex(
name: "IX_expense_categories_Code",
table: "expense_categories",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_expense_categories_Name",
table: "expense_categories",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_expense_payments_CreatedBy",
table: "expense_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_expense_payments_ExpenseId",
table: "expense_payments",
column: "ExpenseId");
migrationBuilder.CreateIndex(
name: "IX_expense_titles_ExpenseCategoryId_Name",
table: "expense_titles",
columns: new[] { "ExpenseCategoryId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_expenses_BranchId",
table: "expenses",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_expenses_CreatedBy",
table: "expenses",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_expenses_DocNo",
table: "expenses",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_expenses_ExpenseCategoryId",
table: "expenses",
column: "ExpenseCategoryId");
migrationBuilder.CreateIndex(
name: "IX_expenses_ExpenseDate",
table: "expenses",
column: "ExpenseDate");
migrationBuilder.CreateIndex(
name: "IX_expenses_ExpenseTitleId",
table: "expenses",
column: "ExpenseTitleId");
migrationBuilder.CreateIndex(
name: "IX_expenses_PaymentStatus",
table: "expenses",
column: "PaymentStatus");
migrationBuilder.CreateIndex(
name: "IX_expenses_Status",
table: "expenses",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_expenses_VendorId",
table: "expenses",
column: "VendorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "expense_payments");
migrationBuilder.DropTable(
name: "expenses");
migrationBuilder.DropTable(
name: "expense_titles");
migrationBuilder.DropTable(
name: "expense_categories");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 54);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 55);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 44);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 45);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <summary>
/// Data-only: grants the two new Accounts sub-items (NAV:accounts.expenses = 54,
/// NAV:accounts.expense-setup = 55, added by AddExpenseModule) to the Admin role and to every
/// role that already reaches Accounts (NAV:accounts 32, bank-accounts 26, cheque-books 33,
/// received-cheques 34). Without this no role — Admin included — has the codes in
/// /auth/me's navCodes, so the sidebar hides the screens. Admins can still revoke them per
/// role afterwards. INSERT IGNORE keeps it idempotent against grants made by hand.
/// </summary>
public partial class GrantExpensePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, p.`PermissionId`, 31
FROM `roles` r
CROSS JOIN (SELECT 54 AS `PermissionId` UNION ALL SELECT 55) p
WHERE LOWER(r.`Code`) = 'admin'
OR EXISTS (SELECT 1 FROM `role_permissions` rp
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` IN (26, 32, 33, 34));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` IN (54, 55);");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class UpdateExpenseGlCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 1,
column: "GlExpenseAccountCode",
value: "5200-01");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 2,
column: "GlExpenseAccountCode",
value: "5200-01");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 3,
column: "GlExpenseAccountCode",
value: "5200-01");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 4,
column: "GlExpenseAccountCode",
value: "5200-01");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 5,
column: "GlExpenseAccountCode",
value: "5200-01");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 6,
column: "GlExpenseAccountCode",
value: "5200-02");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 7,
column: "GlExpenseAccountCode",
value: "5200-02");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 8,
column: "GlExpenseAccountCode",
value: "5200-02");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 9,
column: "GlExpenseAccountCode",
value: "5200-03");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 10,
column: "GlExpenseAccountCode",
value: "5200-03");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 11,
column: "GlExpenseAccountCode",
value: "5200-03");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 12,
column: "GlExpenseAccountCode",
value: "5200-03");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 13,
column: "GlExpenseAccountCode",
value: "5200-03");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 14,
column: "GlExpenseAccountCode",
value: "5200-04");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 15,
column: "GlExpenseAccountCode",
value: "5200-04");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 16,
column: "GlExpenseAccountCode",
value: "5200-04");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 17,
column: "GlExpenseAccountCode",
value: "5200-04");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 18,
column: "GlExpenseAccountCode",
value: "5200-04");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 1,
column: "GlExpenseAccountCode",
value: "6100");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 2,
column: "GlExpenseAccountCode",
value: "6100");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 3,
column: "GlExpenseAccountCode",
value: "6100");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 4,
column: "GlExpenseAccountCode",
value: "6100");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 5,
column: "GlExpenseAccountCode",
value: "6100");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 6,
column: "GlExpenseAccountCode",
value: "6200");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 7,
column: "GlExpenseAccountCode",
value: "6200");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 8,
column: "GlExpenseAccountCode",
value: "6200");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 9,
column: "GlExpenseAccountCode",
value: "6300");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 10,
column: "GlExpenseAccountCode",
value: "6300");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 11,
column: "GlExpenseAccountCode",
value: "6300");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 12,
column: "GlExpenseAccountCode",
value: "6300");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 13,
column: "GlExpenseAccountCode",
value: "6300");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 14,
column: "GlExpenseAccountCode",
value: "6900");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 15,
column: "GlExpenseAccountCode",
value: "6900");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 16,
column: "GlExpenseAccountCode",
value: "6900");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 17,
column: "GlExpenseAccountCode",
value: "6900");
migrationBuilder.UpdateData(
table: "expense_titles",
keyColumn: "ExpenseTitleId",
keyValue: 18,
column: "GlExpenseAccountCode",
value: "6900");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class RenameDayEndToApprovedInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 42,
column: "Label",
value: "Approved Invoices");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 42,
column: "Label",
value: "Day End");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesPaymentMethodsAndDayEndReport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CardAmount",
table: "sales_slips",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "CashAmount",
table: "sales_slips",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "PaymentMethod",
table: "sales_invoice_payments",
type: "varchar(20)",
maxLength: 20,
nullable: false,
defaultValue: "Cash",
collation: "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<decimal>(
name: "CardAmount",
table: "bundle_sales",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "CashAmount",
table: "bundle_sales",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 46, "sales.day-end-report", "/dashboard/sales/day-end-report", null, "Day-End Report", 13, 5 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 56, "NAV:sales.day-end-report", null, 46 });
// Every sale before the cash/card split existed was treated as cash (see SalesDayEnd's
// PaidTotal), so existing slips/bundles are all cash; invoice payments already default
// to 'Cash' through the column default above.
migrationBuilder.Sql("UPDATE `sales_slips` SET `CashAmount` = `PaidAmount`, `CardAmount` = 0;");
migrationBuilder.Sql("UPDATE `bundle_sales` SET `CashAmount` = `GrandTotal`, `CardAmount` = 0;");
// Grant the new report (56) to Admin and to every role that already reaches the
// cashier close-out (NAV:sales.day-end = 52) — it shows income, so it follows the
// close-out rather than plain invoicing. Same idempotent pattern as GrantExpensePermissions.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 56, 31
FROM `roles` r
WHERE LOWER(r.`Code`) = 'admin'
OR EXISTS (SELECT 1 FROM `role_permissions` rp
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` = 52);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 56;");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 56);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 46);
migrationBuilder.DropColumn(
name: "CardAmount",
table: "sales_slips");
migrationBuilder.DropColumn(
name: "CashAmount",
table: "sales_slips");
migrationBuilder.DropColumn(
name: "PaymentMethod",
table: "sales_invoice_payments");
migrationBuilder.DropColumn(
name: "CardAmount",
table: "bundle_sales");
migrationBuilder.DropColumn(
name: "CashAmount",
table: "bundle_sales");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBankDepositModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "bank_deposits",
columns: table => new
{
BankDepositId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DocNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
BranchId = table.Column<int>(type: "int", nullable: false),
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
DepositDate = table.Column<DateOnly>(type: "date", nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
FromGlCashAccountId = table.Column<long>(type: "bigint", nullable: false),
FromCashAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
FromCashAccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
ToGlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
ToBankAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
ToBankAccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
ToBankName = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true, collation: "utf8mb4_unicode_ci"),
ToAccountNumber = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true, collation: "utf8mb4_unicode_ci"),
DepositSlipNo = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true, collation: "utf8mb4_unicode_ci"),
DepositedBy = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true, collation: "utf8mb4_unicode_ci"),
Remarks = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true, collation: "utf8mb4_unicode_ci"),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Posted", collation: "utf8mb4_unicode_ci"),
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
GlPostedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
GlReversalJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
CancelReason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci"),
CancelledBy = table.Column<int>(type: "int", nullable: true),
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedBy = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_bank_deposits", x => x.BankDepositId);
table.CheckConstraint("ck_bank_deposits_amount", "`Amount` > 0");
table.ForeignKey(
name: "FK_bank_deposits_hr_branches_BranchId",
column: x => x.BranchId,
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bank_deposits_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 47, "accounts.bank-deposits", "/dashboard/accounts/bank-deposits", null, "Bank Deposits", 12, 6 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 57, "NAV:accounts.bank-deposits", null, 47 });
migrationBuilder.CreateIndex(
name: "IX_bank_deposits_BranchId_BusinessDate_Status",
table: "bank_deposits",
columns: new[] { "BranchId", "BusinessDate", "Status" });
migrationBuilder.CreateIndex(
name: "IX_bank_deposits_CreatedBy",
table: "bank_deposits",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_bank_deposits_DocNo",
table: "bank_deposits",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_bank_deposits_ToGlBankAccountId",
table: "bank_deposits",
column: "ToGlBankAccountId");
// Grant the new Accounts sub-item (57) to Admin and to every role that already reaches
// Accounts (NAV:accounts 32, bank-accounts 26, cheque-books 33, received-cheques 34,
// expenses 54). Actions 31 = all, so Create (record) and Delete (cancel) included; admins
// can narrow it per role afterwards. Same idempotent pattern as GrantExpensePermissions.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 57, 31
FROM `roles` r
WHERE LOWER(r.`Code`) = 'admin'
OR EXISTS (SELECT 1 FROM `role_permissions` rp
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` IN (26, 32, 33, 34, 54));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 57;");
migrationBuilder.DropTable(
name: "bank_deposits");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 57);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 47);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <summary>
/// Data-only: turns the new "stock.low" (Low Stock / Reorder Alert) module on for the in-app
/// bell. A module with no row is silently off, so without this no alert would ever reach anyone
/// until an admin ticked it under Settings → Notifications. Only inserts when the notification
/// config exists and the module has no row yet, so a choice already made is left alone.
/// </summary>
public partial class EnableLowStockAlerts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
INSERT INTO `notification_config_modules` (`NotificationConfigId`, `ModuleKey`, `EmailEnabled`, `SmsEnabled`, `InAppEnabled`)
SELECT c.`NotificationConfigId`, 'stock.low', 0, 0, 1
FROM `notification_config` c
WHERE c.`NotificationConfigId` = 1
AND NOT EXISTS (SELECT 1 FROM `notification_config_modules` m WHERE m.`ModuleKey` = 'stock.low');
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `notification_config_modules` WHERE `ModuleKey` = 'stock.low';");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,473 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddDualApproval : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Status",
table: "stock_adjustments",
type: "varchar(30)",
maxLength: 30,
nullable: false,
collation: "utf8mb4_unicode_ci",
oldClrType: typeof(string),
oldType: "varchar(20)",
oldMaxLength: 20)
.OldAnnotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<DateTime>(
name: "ApprovedLevel1At",
table: "stock_adjustments",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ApprovedLevel1By",
table: "stock_adjustments",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ApprovedLevel2At",
table: "stock_adjustments",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ApprovedLevel2By",
table: "stock_adjustments",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CancelledAt",
table: "stock_adjustments",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "PostedAt",
table: "stock_adjustments",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RejectReason",
table: "stock_adjustments",
type: "varchar(250)",
maxLength: 250,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<DateTime>(
name: "RejectedAt",
table: "stock_adjustments",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedBy",
table: "stock_adjustments",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RequiredApprovals",
table: "stock_adjustments",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "RequestedSerialIds",
table: "stock_adjustment_lines",
type: "varchar(4000)",
maxLength: 4000,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<string>(
name: "RequestedSerialNos",
table: "stock_adjustment_lines",
type: "varchar(4000)",
maxLength: 4000,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "approval_config",
columns: table => new
{
ApprovalConfigId = table.Column<int>(type: "int", nullable: false),
StockAdjustmentLevels = table.Column<int>(type: "int", nullable: false),
StockAdjustmentLevel2ThresholdValue = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
PriceAdjustmentLevels = table.Column<int>(type: "int", nullable: false),
PriceAdjustmentLevel2ThresholdPercent = table.Column<decimal>(type: "decimal(9,4)", precision: 9, scale: 4, nullable: false),
Level2RoleCodes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true, collation: "utf8mb4_unicode_ci"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_approval_config", x => x.ApprovalConfigId);
table.CheckConstraint("ck_approval_config_singleton", "`ApprovalConfigId` = 1");
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "document_approvals",
columns: table => new
{
DocumentApprovalId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DocType = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
DocId = table.Column<int>(type: "int", nullable: false),
Level = table.Column<int>(type: "int", nullable: false),
Decision = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
DecidedBy = table.Column<int>(type: "int", nullable: false),
DecidedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Comment = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_document_approvals", x => x.DocumentApprovalId);
table.ForeignKey(
name: "FK_document_approvals_users_DecidedBy",
column: x => x.DecidedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "price_adjustments",
columns: table => new
{
PriceAdjustmentId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DocNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
Reason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: false, collation: "utf8mb4_unicode_ci"),
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, defaultValue: "PendingApproval", collation: "utf8mb4_unicode_ci"),
RequiredApprovals = table.Column<int>(type: "int", nullable: false),
CreatedBy = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ApprovedLevel1By = table.Column<int>(type: "int", nullable: true),
ApprovedLevel1At = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ApprovedLevel2By = table.Column<int>(type: "int", nullable: true),
ApprovedLevel2At = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RejectedBy = table.Column<int>(type: "int", nullable: true),
RejectedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RejectReason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci"),
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
AppliedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_price_adjustments", x => x.PriceAdjustmentId);
table.ForeignKey(
name: "FK_price_adjustments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "item_price_history",
columns: table => new
{
ItemPriceHistoryId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ItemId = table.Column<int>(type: "int", nullable: false),
PriceAdjustmentId = table.Column<int>(type: "int", nullable: true),
OldMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
OldMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
OldFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
NewMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
NewMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
NewFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
RequestedBy = table.Column<int>(type: "int", nullable: false),
ApprovedLevel1By = table.Column<int>(type: "int", nullable: true),
ApprovedLevel2By = table.Column<int>(type: "int", nullable: true),
AppliedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_item_price_history", x => x.ItemPriceHistoryId);
table.ForeignKey(
name: "FK_item_price_history_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_item_price_history_price_adjustments_PriceAdjustmentId",
column: x => x.PriceAdjustmentId,
principalTable: "price_adjustments",
principalColumn: "PriceAdjustmentId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "price_adjustment_lines",
columns: table => new
{
PriceAdjustmentLineId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
PriceAdjustmentId = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<int>(type: "int", nullable: false),
OldMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
OldMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
OldFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
NewMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
NewMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
NewFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_price_adjustment_lines", x => x.PriceAdjustmentLineId);
table.ForeignKey(
name: "FK_price_adjustment_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_price_adjustment_lines_price_adjustments_PriceAdjustmentId",
column: x => x.PriceAdjustmentId,
principalTable: "price_adjustments",
principalColumn: "PriceAdjustmentId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.InsertData(
table: "approval_config",
columns: new[] { "ApprovalConfigId", "Level2RoleCodes", "PriceAdjustmentLevel2ThresholdPercent", "PriceAdjustmentLevels", "RowVersion", "StockAdjustmentLevel2ThresholdValue", "StockAdjustmentLevels", "UpdatedAt", "UpdatedBy" },
values: new object[] { 1, null, 0m, 2, 1u, 0m, 2, null, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 48, "products.price-adjustments", "/dashboard/products/price-adjustments", null, "Price Adjustments", 2, 8 },
{ 49, "settings.approvals", "/dashboard/settings/approvals", null, "Approvals", 9, 4 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 58, "NAV:products.price-adjustments", null, 48 },
{ 59, "NAV:settings.approvals", null, 49 }
});
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_Status",
table: "stock_adjustments",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_document_approvals_DecidedBy",
table: "document_approvals",
column: "DecidedBy");
migrationBuilder.CreateIndex(
name: "IX_document_approvals_DocType_DocId",
table: "document_approvals",
columns: new[] { "DocType", "DocId" });
migrationBuilder.CreateIndex(
name: "IX_item_price_history_ItemId_AppliedAt",
table: "item_price_history",
columns: new[] { "ItemId", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_item_price_history_PriceAdjustmentId",
table: "item_price_history",
column: "PriceAdjustmentId");
migrationBuilder.CreateIndex(
name: "IX_price_adjustment_lines_ItemId",
table: "price_adjustment_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_price_adjustment_lines_PriceAdjustmentId",
table: "price_adjustment_lines",
column: "PriceAdjustmentId");
migrationBuilder.CreateIndex(
name: "IX_price_adjustments_CreatedBy",
table: "price_adjustments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_price_adjustments_DocNo",
table: "price_adjustments",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_price_adjustments_Status",
table: "price_adjustments",
column: "Status");
// Existing adjustments all auto-posted, so they need no approval rows — RequiredApprovals = 0
// (the column default) marks them as "posted without approval".
// Price Adjustments (58): Admin and every role that already reaches Products (NAV:products 2,
// products.item 11). Approval settings (59): Admin only. Actions 31 = all, including Approve —
// admins should narrow Approve to the people who really approve (at least two per branch).
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 58, 31
FROM `roles` r
WHERE LOWER(r.`Code`) = 'admin'
OR EXISTS (SELECT 1 FROM `role_permissions` rp
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` IN (2, 11));
""");
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 59, 31 FROM `roles` r WHERE LOWER(r.`Code`) = 'admin';
""");
// The approval notifications are on in the bell by default — without them nobody learns a
// request is waiting. Same guarded insert as EnableLowStockAlerts.
foreach (var key in new[] { "approval.pending", "approval.approved", "approval.rejected" })
{
migrationBuilder.Sql($"""
INSERT INTO `notification_config_modules` (`NotificationConfigId`, `ModuleKey`, `EmailEnabled`, `SmsEnabled`, `InAppEnabled`)
SELECT c.`NotificationConfigId`, '{key}', 0, 0, 1
FROM `notification_config` c
WHERE c.`NotificationConfigId` = 1
AND NOT EXISTS (SELECT 1 FROM `notification_config_modules` m WHERE m.`ModuleKey` = '{key}');
""");
}
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` IN (58, 59);");
migrationBuilder.Sql("DELETE FROM `notification_config_modules` WHERE `ModuleKey` IN ('approval.pending', 'approval.approved', 'approval.rejected');");
migrationBuilder.DropTable(
name: "approval_config");
migrationBuilder.DropTable(
name: "document_approvals");
migrationBuilder.DropTable(
name: "item_price_history");
migrationBuilder.DropTable(
name: "price_adjustment_lines");
migrationBuilder.DropTable(
name: "price_adjustments");
migrationBuilder.DropIndex(
name: "IX_stock_adjustments_Status",
table: "stock_adjustments");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 58);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 59);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 48);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 49);
migrationBuilder.DropColumn(
name: "ApprovedLevel1At",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "ApprovedLevel1By",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "ApprovedLevel2At",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "ApprovedLevel2By",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "CancelledAt",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "PostedAt",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "RejectReason",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "RejectedAt",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "RejectedBy",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "RequiredApprovals",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "RequestedSerialIds",
table: "stock_adjustment_lines");
migrationBuilder.DropColumn(
name: "RequestedSerialNos",
table: "stock_adjustment_lines");
migrationBuilder.AlterColumn<string>(
name: "Status",
table: "stock_adjustments",
type: "varchar(20)",
maxLength: 20,
nullable: false,
collation: "utf8mb4_unicode_ci",
oldClrType: typeof(string),
oldType: "varchar(30)",
oldMaxLength: 30)
.OldAnnotation("Relational:Collation", "utf8mb4_unicode_ci");
}
}
}
@@ -23,6 +23,58 @@ namespace ERPCore.Migrations
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("ERPCore.Domain.Entities.ApprovalConfig", b =>
{
b.Property<int>("ApprovalConfigId")
.HasColumnType("int");
b.Property<string>("Level2RoleCodes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<decimal>("PriceAdjustmentLevel2ThresholdPercent")
.HasPrecision(9, 4)
.HasColumnType("decimal(9,4)");
b.Property<int>("PriceAdjustmentLevels")
.HasColumnType("int");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<decimal>("StockAdjustmentLevel2ThresholdValue")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("StockAdjustmentLevels")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.HasKey("ApprovalConfigId");
b.ToTable("approval_config", null, t =>
{
t.HasCheckConstraint("ck_approval_config_singleton", "`ApprovalConfigId` = 1");
});
b.HasData(
new
{
ApprovalConfigId = 1,
PriceAdjustmentLevel2ThresholdPercent = 0m,
PriceAdjustmentLevels = 2,
RowVersion = 1u,
StockAdjustmentLevel2ThresholdValue = 0m,
StockAdjustmentLevels = 2
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
{ {
b.Property<int>("AttendanceRecordId") b.Property<int>("AttendanceRecordId")
@@ -214,6 +266,133 @@ namespace ERPCore.Migrations
b.ToTable("audit_logs", (string)null); b.ToTable("audit_logs", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.BankDeposit", b =>
{
b.Property<int>("BankDepositId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("BankDepositId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("BranchId")
.HasColumnType("int");
b.Property<DateOnly>("BusinessDate")
.HasColumnType("date");
b.Property<string>("CancelReason")
.HasMaxLength(250)
.HasColumnType("varchar(250)");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime(6)");
b.Property<int?>("CancelledBy")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CreatedBy")
.HasColumnType("int");
b.Property<DateOnly>("DepositDate")
.HasColumnType("date");
b.Property<string>("DepositSlipNo")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("DepositedBy")
.HasMaxLength(150)
.HasColumnType("varchar(150)");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("FromCashAccountCode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("FromCashAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<long>("FromGlCashAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("datetime(6)");
b.Property<string>("GlReversalJournalNo")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("Remarks")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasDefaultValue("Posted");
b.Property<string>("ToAccountNumber")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("ToBankAccountCode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("ToBankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("ToBankName")
.HasMaxLength(150)
.HasColumnType("varchar(150)");
b.Property<long>("ToGlBankAccountId")
.HasColumnType("bigint");
b.HasKey("BankDepositId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("ToGlBankAccountId");
b.HasIndex("BranchId", "BusinessDate", "Status");
b.ToTable("bank_deposits", null, t =>
{
t.HasCheckConstraint("ck_bank_deposits_amount", "`Amount` > 0");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
{ {
b.Property<int>("BatchId") b.Property<int>("BatchId")
@@ -435,6 +614,14 @@ namespace ERPCore.Migrations
b.Property<int>("BundleSaleTemplateId") b.Property<int>("BundleSaleTemplateId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<decimal>("CardAmount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("CashAmount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("CashierUserId") b.Property<int>("CashierUserId")
.HasColumnType("int"); .HasColumnType("int");
@@ -904,6 +1091,49 @@ namespace ERPCore.Migrations
b.ToTable("hr_designations", (string)null); b.ToTable("hr_designations", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.DocumentApproval", b =>
{
b.Property<int>("DocumentApprovalId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("DocumentApprovalId"));
b.Property<string>("Comment")
.HasMaxLength(250)
.HasColumnType("varchar(250)");
b.Property<DateTime>("DecidedAt")
.HasColumnType("datetime(6)");
b.Property<int>("DecidedBy")
.HasColumnType("int");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<int>("DocId")
.HasColumnType("int");
b.Property<string>("DocType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<int>("Level")
.HasColumnType("int");
b.HasKey("DocumentApprovalId");
b.HasIndex("DecidedBy");
b.HasIndex("DocType", "DocId");
b.ToTable("document_approvals", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
{ {
b.Property<int>("EmployeeId") b.Property<int>("EmployeeId")
@@ -1989,6 +2219,65 @@ namespace ERPCore.Migrations
b.ToTable("item_images", (string)null); b.ToTable("item_images", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.ItemPriceHistory", b =>
{
b.Property<int>("ItemPriceHistoryId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("ItemPriceHistoryId"));
b.Property<DateTime>("AppliedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ApprovedLevel1By")
.HasColumnType("int");
b.Property<int?>("ApprovedLevel2By")
.HasColumnType("int");
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<decimal?>("NewFlatPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("NewMaxPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("NewMinPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("OldFlatPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("OldMaxPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("OldMinPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int?>("PriceAdjustmentId")
.HasColumnType("int");
b.Property<int>("RequestedBy")
.HasColumnType("int");
b.HasKey("ItemPriceHistoryId");
b.HasIndex("PriceAdjustmentId");
b.HasIndex("ItemId", "AppliedAt");
b.ToTable("item_price_history", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{ {
b.Property<int>("ReorderId") b.Property<int>("ReorderId")
@@ -3343,6 +3632,42 @@ namespace ERPCore.Migrations
PermissionId = 53, PermissionId = 53,
Code = "NAV:products.tax-rates", Code = "NAV:products.tax-rates",
SubNavItemId = 43 SubNavItemId = 43
},
new
{
PermissionId = 54,
Code = "NAV:accounts.expenses",
SubNavItemId = 44
},
new
{
PermissionId = 55,
Code = "NAV:accounts.expense-setup",
SubNavItemId = 45
},
new
{
PermissionId = 56,
Code = "NAV:sales.day-end-report",
SubNavItemId = 46
},
new
{
PermissionId = 57,
Code = "NAV:accounts.bank-deposits",
SubNavItemId = 47
},
new
{
PermissionId = 58,
Code = "NAV:products.price-adjustments",
SubNavItemId = 48
},
new
{
PermissionId = 59,
Code = "NAV:settings.approvals",
SubNavItemId = 49
}); });
}); });
@@ -3390,6 +3715,131 @@ namespace ERPCore.Migrations
b.ToTable("po_lines", (string)null); b.ToTable("po_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustment", b =>
{
b.Property<int>("PriceAdjustmentId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("PriceAdjustmentId"));
b.Property<DateTime?>("AppliedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("ApprovedLevel1At")
.HasColumnType("datetime(6)");
b.Property<int?>("ApprovedLevel1By")
.HasColumnType("int");
b.Property<DateTime?>("ApprovedLevel2At")
.HasColumnType("datetime(6)");
b.Property<int?>("ApprovedLevel2By")
.HasColumnType("int");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CreatedBy")
.HasColumnType("int");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("varchar(250)");
b.Property<string>("RejectReason")
.HasMaxLength(250)
.HasColumnType("varchar(250)");
b.Property<DateTime?>("RejectedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("RejectedBy")
.HasColumnType("int");
b.Property<int>("RequiredApprovals")
.HasColumnType("int");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("varchar(30)")
.HasDefaultValue("PendingApproval");
b.HasKey("PriceAdjustmentId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("Status");
b.ToTable("price_adjustments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustmentLine", b =>
{
b.Property<int>("PriceAdjustmentLineId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("PriceAdjustmentLineId"));
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<decimal?>("NewFlatPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("NewMaxPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("NewMinPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("OldFlatPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("OldMaxPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("OldMinPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("PriceAdjustmentId")
.HasColumnType("int");
b.HasKey("PriceAdjustmentLineId");
b.HasIndex("ItemId");
b.HasIndex("PriceAdjustmentId");
b.ToTable("price_adjustment_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{ {
b.Property<int>("ConfigId") b.Property<int>("ConfigId")
@@ -4910,6 +5360,13 @@ namespace ERPCore.Migrations
b.Property<DateTime>("PaymentDate") b.Property<DateTime>("PaymentDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<string>("PaymentMethod")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasDefaultValue("Cash");
b.Property<string>("Reference") b.Property<string>("Reference")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("varchar(100)"); .HasColumnType("varchar(100)");
@@ -5112,6 +5569,14 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
b.Property<decimal>("CardAmount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("CashAmount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("CashierUserId") b.Property<int>("CashierUserId")
.HasColumnType("int"); .HasColumnType("int");
@@ -5549,9 +6014,24 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("AdjustmentId")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("AdjustmentId"));
b.Property<DateTime?>("ApprovedLevel1At")
.HasColumnType("datetime(6)");
b.Property<int?>("ApprovedLevel1By")
.HasColumnType("int");
b.Property<DateTime?>("ApprovedLevel2At")
.HasColumnType("datetime(6)");
b.Property<int?>("ApprovedLevel2By")
.HasColumnType("int");
b.Property<int>("BranchId") b.Property<int>("BranchId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -5570,17 +6050,33 @@ namespace ERPCore.Migrations
b.Property<DateTime?>("GlPostedAt") b.Property<DateTime?>("GlPostedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<DateTime?>("PostedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ReasonCodeId") b.Property<int>("ReasonCodeId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("RejectReason")
.HasMaxLength(250)
.HasColumnType("varchar(250)");
b.Property<DateTime?>("RejectedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("RejectedBy")
.HasColumnType("int");
b.Property<int>("RequiredApprovals")
.HasColumnType("int");
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("int unsigned"); .HasColumnType("int unsigned");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(30)
.HasColumnType("varchar(20)"); .HasColumnType("varchar(30)");
b.Property<int>("WarehouseId") b.Property<int>("WarehouseId")
.HasColumnType("int"); .HasColumnType("int");
@@ -5596,6 +6092,8 @@ namespace ERPCore.Migrations
b.HasIndex("ReasonCodeId"); b.HasIndex("ReasonCodeId");
b.HasIndex("Status");
b.HasIndex("WarehouseId"); b.HasIndex("WarehouseId");
b.ToTable("stock_adjustments", (string)null); b.ToTable("stock_adjustments", (string)null);
@@ -5625,6 +6123,14 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
b.Property<string>("RequestedSerialIds")
.HasMaxLength(4000)
.HasColumnType("varchar(4000)");
b.Property<string>("RequestedSerialNos")
.HasMaxLength(4000)
.HasColumnType("varchar(4000)");
b.Property<int?>("SerialId") b.Property<int?>("SerialId")
.HasColumnType("int"); .HasColumnType("int");
@@ -6189,6 +6695,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 48,
Code = "products.price-adjustments",
Href = "/dashboard/products/price-adjustments",
Label = "Price Adjustments",
NavItemId = 2,
SortOrder = 8,
Status = "Active"
},
new
{ {
SubNavItemId = 29, SubNavItemId = 29,
Code = "settings.branches", Code = "settings.branches",
@@ -6229,6 +6745,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 49,
Code = "settings.approvals",
Href = "/dashboard/settings/approvals",
Label = "Approvals",
NavItemId = 9,
SortOrder = 4,
Status = "Active"
},
new
{ {
SubNavItemId = 40, SubNavItemId = 40,
Code = "sales.invoices", Code = "sales.invoices",
@@ -6263,12 +6789,22 @@ namespace ERPCore.Migrations
SubNavItemId = 42, SubNavItemId = 42,
Code = "sales.day-end", Code = "sales.day-end",
Href = "/dashboard/sales/day-end", Href = "/dashboard/sales/day-end",
Label = "Day End", Label = "Approved Invoices",
NavItemId = 13, NavItemId = 13,
SortOrder = 4, SortOrder = 4,
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 46,
Code = "sales.day-end-report",
Href = "/dashboard/sales/day-end-report",
Label = "Day-End Report",
NavItemId = 13,
SortOrder = 5,
Status = "Active"
},
new
{ {
SubNavItemId = 30, SubNavItemId = 30,
Code = "procurement.vendors", Code = "procurement.vendors",
@@ -6429,6 +6965,36 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 44,
Code = "accounts.expenses",
Href = "/dashboard/accounts/expenses",
Label = "Expenses",
NavItemId = 12,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 45,
Code = "accounts.expense-setup",
Href = "/dashboard/accounts/expense-setup",
Label = "Expense Setup",
NavItemId = 12,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 47,
Code = "accounts.bank-deposits",
Href = "/dashboard/accounts/bank-deposits",
Label = "Bank Deposits",
NavItemId = 12,
SortOrder = 6,
Status = "Active"
},
new
{ {
SubNavItemId = 27, SubNavItemId = 27,
Code = "production.templates", Code = "production.templates",
@@ -7245,6 +7811,25 @@ namespace ERPCore.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.BankDeposit", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Item", "Item") b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -7415,6 +8000,17 @@ namespace ERPCore.Migrations
b.Navigation("ParentDepartment"); b.Navigation("ParentDepartment");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.DocumentApproval", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Decider")
.WithMany()
.HasForeignKey("DecidedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Decider");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
@@ -7746,6 +8342,24 @@ namespace ERPCore.Migrations
b.Navigation("Item"); b.Navigation("Item");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.ItemPriceHistory", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PriceAdjustment", "PriceAdjustment")
.WithMany()
.HasForeignKey("PriceAdjustmentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
b.Navigation("PriceAdjustment");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Item", "Item") b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -7985,6 +8599,36 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse"); b.Navigation("Warehouse");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustmentLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PriceAdjustment", "PriceAdjustment")
.WithMany("Lines")
.HasForeignKey("PriceAdjustmentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("PriceAdjustment");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
@@ -9377,6 +10021,16 @@ namespace ERPCore.Migrations
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.Expense", b =>
{
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ExpenseCategory", b =>
{
b.Navigation("Titles");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
@@ -9419,6 +10073,11 @@ namespace ERPCore.Migrations
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustment", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b =>
{ {
b.Navigation("Edges"); b.Navigation("Edges");
+26 -2
View File
@@ -28,7 +28,7 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day)); .WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase). // Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
builder.Services.AddControllers() builder.Services.AddControllers(o => o.Filters.Add<ERPCore.Infra.Filters.LowStockAlertFilter>())
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using // EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using
@@ -149,6 +149,19 @@ builder.Services.AddScoped<IGrnService, GrnService>();
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>(); builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>(); builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
// Accounts: expenses (docs/EXPENSE_MODULE_PLAN.md). ExpenseService is also registered as itself:
// ExpensePaymentService reuses its post step so Post & Pay is one transaction.
builder.Services.AddScoped<IExpenseSetupService, ExpenseSetupService>();
builder.Services.AddScoped<ExpenseService>();
builder.Services.AddScoped<IExpenseService>(sp => sp.GetRequiredService<ExpenseService>());
builder.Services.AddScoped<IExpensePaymentService, ExpensePaymentService>();
// Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md). The business-day clock
// is shared with the Day-End Sales Report so both agree on what "today" is.
builder.Services.AddSingleton<BusinessDayClock>();
builder.Services.AddScoped<ICashInHandService, CashInHandService>();
builder.Services.AddScoped<IBankDepositService, BankDepositService>();
// Sales (Phase 1) // Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>(); builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>(); builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
@@ -163,16 +176,27 @@ builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSugge
builder.Services.AddScoped<ISalesReportService, SalesReportService>(); builder.Services.AddScoped<ISalesReportService, SalesReportService>();
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>(); builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>(); builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
builder.Services.AddScoped<IDayEndReportService, DayEndReportService>();
// Stock transactions + reference data (docs/11 §5–6) // Stock transactions + reference data (docs/11 §5–6)
builder.Services.AddScoped<IStockMutator, StockMutator>(); builder.Services.AddScoped<IStockMutator, StockMutator>();
builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>(); builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>();
builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>(); builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>();
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>(); builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
builder.Services.AddScoped<IAdjustmentService, AdjustmentService>(); // AdjustmentService is also registered as itself: CountService submits its variances through it.
builder.Services.AddScoped<AdjustmentService>();
builder.Services.AddScoped<IAdjustmentService>(sp => sp.GetRequiredService<AdjustmentService>());
builder.Services.AddScoped<IApprovalService, ApprovalService>();
builder.Services.AddScoped<IPriceAdjustmentService, PriceAdjustmentService>();
builder.Services.AddScoped<ApprovalInboxService>();
builder.Services.AddScoped<ITransferService, TransferService>(); builder.Services.AddScoped<ITransferService, TransferService>();
builder.Services.AddScoped<ICountService, CountService>(); builder.Services.AddScoped<ICountService, CountService>();
builder.Services.AddScoped<IReorderService, ReorderService>(); builder.Services.AddScoped<IReorderService, ReorderService>();
// Low-stock alerts: FIFO issues record into the tracker; the global LowStockAlertFilter checks
// them against reorder points after each successful request and raises "stock.low".
builder.Services.AddScoped<ERPCore.Services.Stock.LowStockTracker>();
builder.Services.AddScoped<ERPCore.Services.Stock.LowStockAlertService>();
builder.Services.AddScoped<ERPCore.Infra.Filters.LowStockAlertFilter>();
builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>(); builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) // Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
+300 -61
View File
@@ -1,9 +1,11 @@
using System.Text.Json;
using ERPCore.Domain; using ERPCore.Domain;
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock; using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl; using ERPCore.Services.Gl;
@@ -15,27 +17,36 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
/// <summary> /// <summary>
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts /// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). An adjustment is a
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a /// request with a mandatory reason code; it moves stock only once approved by one or two people
/// decrease, layer create on an increase) + ledger posting is delegated to /// other than the requester (docs/DUAL_APPROVAL_PLAN.md §5, rules in <see cref="IApprovalService"/>).
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05). Also posts /// With approvals switched off it posts on create, as it always used to. Posting — FIFO consume on a
/// a real GL journal entry — increases and decreases post as separate Inventory/Gain and /// decrease, layer create on an increase, ledger, serial moves and the GL journal — is one
/// Loss/Inventory lines respectively (never netted against each other), so a count that's /// transaction (NFR-02/05) and is re-validated at approval time, since stock and serials may have
/// simultaneously over on one item and under on another shows both, not a false net. /// moved while the request waited. Increases and decreases post as separate Inventory/Gain and
/// Loss/Inventory GL lines (never netted), so a count over on one item and under on another shows both.
/// </summary> /// </summary>
public sealed class AdjustmentService : IAdjustmentService public sealed class AdjustmentService : IAdjustmentService
{ {
public const string ApprovalDocType = "ADJ";
public const string ApprovalNavCode = "stock.adjustments";
private static readonly IReadOnlyCollection<SerialStatus> WriteOffStatuses = [SerialStatus.InStock, SerialStatus.Damaged];
private readonly IRepository<StockAdjustment> _adjustments; private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items; private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes; private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator; private readonly IStockMutator _mutator;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly IApprovalService _approvals;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly ErpDbContext _db;
private readonly ISerialTrackingService _serialTracking; private readonly ISerialTrackingService _serialTracking;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
@@ -53,18 +64,19 @@ public sealed class AdjustmentService : IAdjustmentService
_reasonCodes = reasonCodes; _reasonCodes = reasonCodes;
_ledger = ledger; _ledger = ledger;
_mutator = mutator; _mutator = mutator;
_fifo = fifo;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig; _branchConfig = branchConfig;
_notifications = notifications; _notifications = notifications;
_approvals = approvals;
_uow = uow; _uow = uow;
_db = db;
_gl = gl; _gl = gl;
_glAccounts = glAccounts; _glAccounts = glAccounts;
} }
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement. /// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.</summary>
/// <see cref="CountService"/>'s variance posting mirrors this exact logic for the same reason
/// (it creates a <see cref="StockAdjustment"/> through the same <see cref="IStockMutator"/> call).</summary>
private async Task<string?> PostAdjustmentJournalAsync( private async Task<string?> PostAdjustmentJournalAsync(
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct) string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
{ {
@@ -99,7 +111,7 @@ public sealed class AdjustmentService : IAdjustmentService
} }
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync( public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default) PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default)
{ {
var q = _adjustments.Query().AsNoTracking(); var q = _adjustments.Query().AsNoTracking();
@@ -112,13 +124,15 @@ public sealed class AdjustmentService : IAdjustmentService
// Warehouses are shared across branches — the adjustment carries its own owning BranchId. // Warehouses are shared across branches — the adjustment carries its own owning BranchId.
if (branchId is not null) q = q.Where(a => a.BranchId == branchId); if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId); if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
if (status is not null) q = q.Where(a => a.Status == status);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId) var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto( .Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo)) a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo, a.RequiredApprovals,
a.Status == AdjustmentStatus.PendingApproval ? 1 : a.Status == AdjustmentStatus.PendingSecondApproval ? 2 : null))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -129,20 +143,22 @@ public sealed class AdjustmentService : IAdjustmentService
var adjustment = await _adjustments.Query().AsNoTracking() var adjustment = await _adjustments.Query().AsNoTracking()
.Include(a => a.Lines) .Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct); .FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
if (adjustment is null) return null; return adjustment is null ? null : await ToDtoAsync(adjustment, ct);
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(adjustment, ledgerRefs);
} }
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default) public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
var (adjustment, refs) = await SubmitAsync(request, null, ct);
return await ToDtoAsync(adjustment, ct, refs.Select(l => l.LedgerId).ToList());
}
/// <summary>
/// Validates and saves an adjustment request; posts it straight away when no approval is
/// required. <paramref name="alsoInTransaction"/> lets a caller (a stock count) commit its own
/// change together with the adjustment. Returns the ledger entries posted (none while pending).
/// </summary>
internal async Task<(StockAdjustment Adjustment, IReadOnlyList<StockLedger> Refs)> SubmitAsync(
CreateAdjustmentRequest request, Func<CancellationToken, Task>? alsoInTransaction, CancellationToken ct)
{ {
if (request.ReasonCodeId is null) if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400); throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400);
@@ -157,77 +173,300 @@ public sealed class AdjustmentService : IAdjustmentService
if (reason.Context != ReasonContext.Adjustment) if (reason.Context != ReasonContext.Adjustment)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422); throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
// Serials per line: outbound ones are validated as on hand; inbound ones are created (or // Validate every line (and its serials) now, so a bad request is refused up front rather
// re-found) in the context now and get their ids on the flush below. // than at approval time. The value drives the approval threshold.
var serialsByLine = new List<IReadOnlyList<Serial>>(); var value = 0m;
var documentSerials = new HashSet<int>();
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in request.Lines) foreach (var line in request.Lines)
{ {
if (line.QtyDelta == 0) if (line.QtyDelta == 0)
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422); throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
serialsByLine.Add(line.QtyDelta < 0 value += Math.Abs(line.QtyDelta) * await UnitValueAsync(line.ItemId, request.WarehouseId, ct);
? await _serialTracking.ResolveOutboundAsync(item, request.WarehouseId, -line.QtyDelta, line.SerialIds,
documentSerials, new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct)
: await _serialTracking.ResolveInboundAsync(item, request.WarehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
} }
await ResolveSerialsAsync(request.WarehouseId, request.Lines.Select(ToPlan).ToList(), ct);
// Validated only: posting resolves them again and creates any new ones exactly once.
DiscardUnsavedSerials();
var required = await _approvals.RequiredForStockAdjustmentAsync(value, ct);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var (adjustment, refs) = await _uow.ExecuteInTransactionAsync(async token =>
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
{ {
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var entity = new StockAdjustment var entity = new StockAdjustment
{ {
DocNo = docNo, DocNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token),
WarehouseId = request.WarehouseId, WarehouseId = request.WarehouseId,
BranchId = branchId, BranchId = branchId,
ReasonCodeId = request.ReasonCodeId.Value, ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted, Status = required == 0 ? AdjustmentStatus.Posted : AdjustmentStatus.PendingApproval,
RequiredApprovals = required,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
CreatedAt = now, CreatedAt = now,
Lines = request.Lines.Select((l, i) => new StockAdjustmentLine Lines = request.Lines.Select(l => new StockAdjustmentLine
{ {
ItemId = l.ItemId, ItemId = l.ItemId,
BinId = l.BinId, BinId = l.BinId,
BatchId = l.BatchId, BatchId = l.BatchId,
QtyDelta = l.QtyDelta, QtyDelta = l.QtyDelta,
Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null RequestedSerialIds = l.SerialIds is { Count: > 0 } ? JsonSerializer.Serialize(l.SerialIds) : null,
RequestedSerialNos = l.SerialNos is { Count: > 0 } ? JsonSerializer.Serialize(l.SerialNos) : null
}).ToList() }).ToList()
}; };
await _adjustments.AddAsync(entity, token); await _adjustments.AddAsync(entity, token);
await _uow.SaveChangesAsync(token); // flush so AdjustmentId (and new SerialIds) are valid
var deltas = request.Lines.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta, IReadOnlyList<StockLedger> posted = [];
serialsByLine[i].Select(s => s.SerialId).ToList())).ToList(); if (required == 0)
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
for (var i = 0; i < request.Lines.Count; i++)
{ {
var line = request.Lines[i]; await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
await _serialTracking.MoveAsync(serialsByLine[i], posted = await PostInCurrentTransactionAsync(entity, token);
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
line.QtyDelta < 0 ? null : request.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
DocumentTypes.Adjustment, entity.AdjustmentId, now, token);
} }
if (alsoInTransaction is not null) await alsoInTransaction(token);
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token); return (entity, posted);
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
return (entity, refs);
}, ct); }, ct);
if (required == 0)
{
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was created.", nameof(StockAdjustment), adjustment.AdjustmentId, ct); await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was created.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
}
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList()); else
{
await _approvals.NotifyPendingAsync(Target(adjustment), await SummaryAsync(adjustment, ct), ct);
}
return (adjustment, refs);
} }
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new( public async Task<AdjustmentDto> ApproveAsync(int adjustmentId, string? comment, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(adjustmentId, ct);
var target = Target(adjustment);
var now = DateTime.UtcNow;
var outcome = await _approvals.DecideAsync(target, approve: true, comment, ct);
IReadOnlyList<StockLedger> refs = [];
try
{
refs = await _uow.ExecuteInTransactionAsync(async token =>
{
if (target.CurrentLevel == 1)
{
adjustment.ApprovedLevel1By = _currentUser.AuditUserId;
adjustment.ApprovedLevel1At = now;
}
else
{
adjustment.ApprovedLevel2By = _currentUser.AuditUserId;
adjustment.ApprovedLevel2At = now;
}
if (outcome == ApprovalOutcome.AdvancedToLevel2)
{
adjustment.Status = AdjustmentStatus.PendingSecondApproval;
return (IReadOnlyList<StockLedger>)[];
}
adjustment.Status = AdjustmentStatus.Posted;
return await PostInCurrentTransactionAsync(adjustment, token);
}, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
if (outcome == ApprovalOutcome.AdvancedToLevel2)
{
await _approvals.NotifyPendingAsync(Target(adjustment), await SummaryAsync(adjustment, ct), ct);
}
else
{
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.approved",
$"Approved: {adjustment.DocNo}", $"Stock adjustment {adjustment.DocNo} was approved and posted to stock.",
ApprovalDocType, adjustment.AdjustmentId, ct);
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was posted.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
}
return await ToDtoAsync(adjustment, ct, refs.Select(r => r.LedgerId).ToList());
}
public async Task<AdjustmentDto> RejectAsync(int adjustmentId, string reason, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(adjustmentId, ct);
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
adjustment.Status = AdjustmentStatus.Rejected;
adjustment.RejectedBy = _currentUser.AuditUserId;
adjustment.RejectedAt = DateTime.UtcNow;
adjustment.RejectReason = reason.Trim();
await SaveDecisionAsync(adjustment, ct);
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
$"Rejected: {adjustment.DocNo}", $"Stock adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}",
ApprovalDocType, adjustment.AdjustmentId, ct);
return await ToDtoAsync(adjustment, ct);
}
public async Task<AdjustmentDto> CancelAsync(int adjustmentId, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(adjustmentId, ct);
if (adjustment.CreatedBy != _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
adjustment.Status = AdjustmentStatus.Cancelled;
adjustment.CancelledAt = DateTime.UtcNow;
await SaveDecisionAsync(adjustment, ct);
return await ToDtoAsync(adjustment, ct);
}
// --- Posting ---------------------------------------------------------------------------------
/// <summary>What a line asks for, from a request or from a saved pending line.</summary>
private sealed record LinePlan(int ItemId, int? BinId, int? BatchId, decimal QtyDelta, List<int>? SerialIds, List<string>? SerialNos);
private static LinePlan ToPlan(CreateAdjustmentLineInput l) => new(l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialIds, l.SerialNos);
private static LinePlan ToPlan(StockAdjustmentLine l) => new(
l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
l.RequestedSerialIds is null ? null : JsonSerializer.Deserialize<List<int>>(l.RequestedSerialIds),
l.RequestedSerialNos is null ? null : JsonSerializer.Deserialize<List<string>>(l.RequestedSerialNos));
/// <summary>
/// Outbound serials must be at the warehouse (InStock or Damaged); inbound numbers are new or
/// out of stock (new ones are added to the context, unsaved). Same rules on request and posting.
/// </summary>
private async Task<List<IReadOnlyList<Serial>>> ResolveSerialsAsync(int warehouseId, IReadOnlyList<LinePlan> lines, CancellationToken ct)
{
var result = new List<IReadOnlyList<Serial>>();
var documentSerials = new HashSet<int>();
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct);
result.Add(line.QtyDelta < 0
? await _serialTracking.ResolveOutboundAsync(item, warehouseId, -line.QtyDelta, line.SerialIds, documentSerials, WriteOffStatuses, ct)
: await _serialTracking.ResolveInboundAsync(item, warehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
}
return result;
}
/// <summary>Drops serials a pending request's validation added, so they aren't created early.</summary>
private void DiscardUnsavedSerials()
{
foreach (var entry in _db.ChangeTracker.Entries<Serial>().Where(e => e.State == EntityState.Added).ToList())
entry.State = EntityState.Detached;
}
/// <summary>
/// Applies a saved adjustment to stock: re-resolves its serials, FIFO-consumes / adds layers,
/// writes the ledger, moves serials and posts the GL journal. Runs inside the caller's transaction;
/// throws (rolling everything back) if stock or serials no longer allow it.
/// </summary>
private async Task<IReadOnlyList<StockLedger>> PostInCurrentTransactionAsync(StockAdjustment entity, CancellationToken ct)
{
var now = DateTime.UtcNow;
var lines = entity.Lines.OrderBy(l => l.AdjLineId).ToList();
var plans = lines.Select(ToPlan).ToList();
var serialsByLine = await ResolveSerialsAsync(entity.WarehouseId, plans, ct);
for (var i = 0; i < lines.Count; i++)
lines[i].Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null;
await _uow.SaveChangesAsync(ct); // new serials get their ids
var deltas = plans.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
serialsByLine[i].Select(s => s.SerialId).ToList())).ToList();
var refs = await _mutator.ApplyAsync(entity.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, ct);
for (var i = 0; i < plans.Count; i++)
{
var line = plans[i];
await _serialTracking.MoveAsync(serialsByLine[i],
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
line.QtyDelta < 0 ? null : entity.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
DocumentTypes.Adjustment, entity.AdjustmentId, now, ct);
}
entity.GlJournalNo = await PostAdjustmentJournalAsync(entity.DocNo, now, refs, ct);
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
entity.PostedAt = now;
return refs;
}
// --- Helpers ---------------------------------------------------------------------------------
/// <summary>Average FIFO cost per unit at the warehouse — prices the request for the approval threshold.</summary>
private async Task<decimal> UnitValueAsync(int itemId, int warehouseId, CancellationToken ct)
{
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
}
private async Task<StockAdjustment> LoadPendingAsync(int adjustmentId, CancellationToken ct)
{
var adjustment = await _adjustments.Query().Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct)
?? throw new NotFoundException($"Stock adjustment {adjustmentId} was not found.");
if (adjustment.Status is not (AdjustmentStatus.PendingApproval or AdjustmentStatus.PendingSecondApproval))
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
return adjustment;
}
private async Task SaveDecisionAsync(StockAdjustment adjustment, CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
}
private static int? PendingLevel(StockAdjustment a) => a.Status switch
{
AdjustmentStatus.PendingApproval => 1,
AdjustmentStatus.PendingSecondApproval => 2,
_ => null
};
private static ApprovalTarget Target(StockAdjustment a) => new(
ApprovalDocType, a.AdjustmentId, a.DocNo, ApprovalNavCode, a.CreatedBy, a.RequiredApprovals,
PendingLevel(a) ?? 1, a.ApprovedLevel1By);
private async Task<string> SummaryAsync(StockAdjustment a, CancellationToken ct)
{
var warehouse = await _warehouses.Query().AsNoTracking().Where(w => w.WarehouseId == a.WarehouseId).Select(w => w.Code).FirstOrDefaultAsync(ct);
var reason = await _reasonCodes.Query().AsNoTracking().Where(r => r.ReasonCodeId == a.ReasonCodeId).Select(r => r.Description).FirstOrDefaultAsync(ct);
return $"Stock adjustment {a.DocNo} ({a.Lines.Count} line{(a.Lines.Count == 1 ? "" : "s")}, {warehouse}, {reason})";
}
private async Task<AdjustmentDto> ToDtoAsync(StockAdjustment a, CancellationToken ct, IReadOnlyList<int>? ledgerRefs = null)
{
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
ledgerRefs ??= await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == a.AdjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
var pending = PendingLevel(a);
var whyNot = pending is null ? "Not waiting for approval." : await _approvals.WhyCannotDecideAsync(Target(a), ct);
return new AdjustmentDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt, a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt, a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId) a.Lines.OrderBy(l => l.AdjLineId).Select(l =>
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId)).ToList(), {
ledgerRefs); var plan = ToPlan(l);
return new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId, plan.SerialIds, plan.SerialNos);
}).ToList(),
ledgerRefs,
a.RequiredApprovals, pending,
a.ApprovedLevel1By, a.ApprovedLevel1At, a.ApprovedLevel2By, a.ApprovedLevel2At,
a.RejectedBy, a.RejectedAt, a.RejectReason, a.PostedAt,
await _approvals.HistoryAsync(ApprovalDocType, a.AdjustmentId, ct),
whyNot is null, whyNot,
pending is not null && a.CreatedBy == _currentUser.AuditUserId,
await _db.Users.AsNoTracking().Where(u => u.UserId == a.CreatedBy).Select(u => u.DisplayName).FirstOrDefaultAsync(ct));
}
} }
@@ -0,0 +1,93 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// The Approvals inbox (docs/DUAL_APPROVAL_PLAN.md §10): every pending stock and price adjustment
/// the current user can approve, plus their own requests (shown as waiting for others). A branch
/// user only sees their branch's stock adjustments; price changes are company-wide.
/// </summary>
public sealed class ApprovalInboxService
{
private readonly IRepository<StockAdjustment> _stock;
private readonly IRepository<PriceAdjustment> _prices;
private readonly IApprovalService _approvals;
private readonly INavPermissionService _nav;
private readonly ICurrentUser _currentUser;
private readonly ICurrentBranchContext _branch;
public ApprovalInboxService(
IRepository<StockAdjustment> stock, IRepository<PriceAdjustment> prices, IApprovalService approvals,
INavPermissionService nav, ICurrentUser currentUser, ICurrentBranchContext branch)
{
_stock = stock;
_prices = prices;
_approvals = approvals;
_nav = nav;
_currentUser = currentUser;
_branch = branch;
}
public async Task<PendingApprovalsDto> PendingAsync(CancellationToken ct = default)
{
var me = _currentUser.AuditUserId;
var canStock = await CanApproveScreenAsync(AdjustmentService.ApprovalNavCode, ct);
var canPrice = await CanApproveScreenAsync(PriceAdjustmentService.ApprovalNavCode, ct);
var items = new List<PendingApprovalDto>();
var stockQuery = _stock.Query().AsNoTracking()
.Where(a => a.Status == AdjustmentStatus.PendingApproval || a.Status == AdjustmentStatus.PendingSecondApproval)
.Where(a => canStock || a.CreatedBy == me);
if (_branch.IsActive) stockQuery = stockQuery.Where(a => a.BranchId == _branch.BranchId);
var stock = await stockQuery
.Select(a => new
{
a.AdjustmentId, a.DocNo, a.Status, a.RequiredApprovals, a.CreatedBy, a.CreatedAt, a.ApprovedLevel1By,
RequestedByName = a.Creator!.DisplayName, Warehouse = a.Warehouse!.Code, Reason = a.ReasonCode!.Description, Lines = a.Lines.Count
})
.ToListAsync(ct);
foreach (var a in stock)
{
var level = a.Status == AdjustmentStatus.PendingApproval ? 1 : 2;
var target = new ApprovalTarget(AdjustmentService.ApprovalDocType, a.AdjustmentId, a.DocNo, AdjustmentService.ApprovalNavCode,
a.CreatedBy, a.RequiredApprovals, level, a.ApprovedLevel1By);
var why = canStock ? await _approvals.WhyCannotDecideAsync(target, ct) : "Waiting for an approver.";
items.Add(new PendingApprovalDto(AdjustmentService.ApprovalDocType, a.AdjustmentId, a.DocNo,
$"Stock adjustment — {a.Lines} line{(a.Lines == 1 ? "" : "s")}, {a.Warehouse}, {a.Reason}",
level, a.RequiredApprovals, a.CreatedBy, a.RequestedByName, a.CreatedAt, why is null, why));
}
var prices = await _prices.Query().AsNoTracking()
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
.Where(p => canPrice || p.CreatedBy == me)
.Select(p => new
{
p.PriceAdjustmentId, p.DocNo, p.Status, p.RequiredApprovals, p.CreatedBy, p.CreatedAt, p.ApprovedLevel1By,
RequestedByName = p.Creator!.DisplayName, p.Reason, Lines = p.Lines.Count
})
.ToListAsync(ct);
foreach (var p in prices)
{
var level = p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : 2;
var target = new ApprovalTarget(PriceAdjustmentService.ApprovalDocType, p.PriceAdjustmentId, p.DocNo, PriceAdjustmentService.ApprovalNavCode,
p.CreatedBy, p.RequiredApprovals, level, p.ApprovedLevel1By);
var why = canPrice ? await _approvals.WhyCannotDecideAsync(target, ct) : "Waiting for an approver.";
items.Add(new PendingApprovalDto(PriceAdjustmentService.ApprovalDocType, p.PriceAdjustmentId, p.DocNo,
$"Price adjustment — {p.Lines} item{(p.Lines == 1 ? "" : "s")}: {p.Reason}",
level, p.RequiredApprovals, p.CreatedBy, p.RequestedByName, p.CreatedAt, why is null, why));
}
// Actionable first, then oldest first — the longest-waiting request is what needs attention.
var ordered = items.OrderByDescending(i => i.CanApprove).ThenBy(i => i.RequestedAt).ToList();
return new PendingApprovalsDto(ordered, ordered.Count(i => i.CanApprove));
}
private async Task<bool> CanApproveScreenAsync(string navCode, CancellationToken ct)
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleCode, navCode, RolePermissionAction.Approve, ct);
}
+178
View File
@@ -0,0 +1,178 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Shared approval engine for stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §4). Holds the
/// maker–checker rules in one place; the document services call <see cref="DecideAsync"/> and apply
/// their effect only on <see cref="ApprovalOutcome.FullyApproved"/>. There is no Admin bypass of the
/// two-person rule — an Admin can approve, but never their own request and never both levels.
/// </summary>
public sealed class ApprovalService : IApprovalService
{
private const string AdminRoleCode = "Admin";
private readonly IRepository<ApprovalConfig> _config;
private readonly IRepository<DocumentApproval> _approvals;
private readonly IRepository<User> _users;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public ApprovalService(
IRepository<ApprovalConfig> config, IRepository<DocumentApproval> approvals, IRepository<User> users,
IRepository<RolePermission> rolePermissions, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow)
{
_config = config;
_approvals = approvals;
_users = users;
_rolePermissions = rolePermissions;
_currentUser = currentUser;
_notifications = notifications;
_uow = uow;
}
public async Task<ApprovalConfigDto> GetConfigAsync(CancellationToken ct = default) => Map(await LoadConfigAsync(ct));
public async Task<ApprovalConfigDto> UpdateConfigAsync(UpdateApprovalConfigRequest request, CancellationToken ct = default)
{
var config = await _config.Query().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct);
if (config is null)
{
config = new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
await _config.AddAsync(config, ct);
}
config.StockAdjustmentLevels = Math.Clamp(request.StockAdjustmentLevels, 0, 2);
config.StockAdjustmentLevel2ThresholdValue = Math.Max(0m, request.StockAdjustmentLevel2ThresholdValue);
config.PriceAdjustmentLevels = Math.Clamp(request.PriceAdjustmentLevels, 0, 2);
config.PriceAdjustmentLevel2ThresholdPercent = Math.Max(0m, request.PriceAdjustmentLevel2ThresholdPercent);
var roles = request.Level2RoleCodes.Select(r => r.Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
config.Level2RoleCodes = roles.Count == 0 ? null : string.Join(",", roles);
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
await _uow.SaveChangesAsync(ct);
return Map(config);
}
public async Task<int> RequiredForStockAdjustmentAsync(decimal absoluteValue, CancellationToken ct = default)
{
var c = await LoadConfigAsync(ct);
return Levels(c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue, absoluteValue);
}
public async Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default)
{
var c = await LoadConfigAsync(ct);
return Levels(c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent, largestChangePercent);
}
/// <summary>Two levels drop to one below a positive threshold; 0 and 1 are taken as configured.</summary>
public static int Levels(int configured, decimal threshold, decimal measure)
=> configured >= 2 && threshold > 0 && measure < threshold ? 1 : Math.Clamp(configured, 0, 2);
public async Task<ApprovalOutcome> DecideAsync(ApprovalTarget target, bool approve, string? comment, CancellationToken ct = default)
{
var (why, code) = await CheckAsync(target, ct);
if (why is not null) throw new DomainException(code!, why, 403);
var note = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
if (!approve && note is null)
throw new DomainException(ErrorCodes.ApprovalReasonRequired, "Give a reason for rejecting.", 400);
await _approvals.AddAsync(new DocumentApproval
{
DocType = target.DocType,
DocId = target.DocId,
Level = target.CurrentLevel,
Decision = approve ? ApprovalDecision.Approved : ApprovalDecision.Rejected,
DecidedBy = _currentUser.AuditUserId,
DecidedAt = DateTime.UtcNow,
Comment = note
}, ct);
if (!approve) return ApprovalOutcome.Rejected;
return target.CurrentLevel >= target.RequiredApprovals ? ApprovalOutcome.FullyApproved : ApprovalOutcome.AdvancedToLevel2;
}
public async Task<string?> WhyCannotDecideAsync(ApprovalTarget target, CancellationToken ct = default)
=> (await CheckAsync(target, ct)).Why;
private async Task<(string? Why, string? Code)> CheckAsync(ApprovalTarget target, CancellationToken ct)
{
var actor = _currentUser.AuditUserId;
if (actor == target.RequestedBy)
return ("You made this request — someone else has to approve it.", ErrorCodes.ApprovalForbidden);
if (target.CurrentLevel >= 2 && target.Level1ApprovedBy == actor)
return ("You gave the first approval — a different person has to give the second.", ErrorCodes.ApprovalSamePerson);
if (target.CurrentLevel >= 2)
{
var roles = Level2Roles(await LoadConfigAsync(ct));
if (roles.Count > 0 && !_currentUser.IsAdmin && !roles.Contains(_currentUser.RoleCode ?? string.Empty))
return ($"Only {string.Join(", ", roles)} can give the second approval.", ErrorCodes.ApprovalRoleRequired);
}
return (null, null);
}
public async Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct = default)
=> await _approvals.Query().AsNoTracking()
.Where(a => a.DocType == docType && a.DocId == docId)
.OrderBy(a => a.DocumentApprovalId)
.Select(a => new ApprovalStepDto(a.Level, a.Decision, a.DecidedBy, a.Decider!.DisplayName, a.DecidedAt, a.Comment))
.ToListAsync(ct);
public async Task<IReadOnlyList<int>> ApproverIdsAsync(ApprovalTarget target, CancellationToken ct = default)
{
var permissionCode = "NAV:" + target.NavCode;
var grants = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.Permission!.Code == permissionCode)
.Select(rp => new { rp.RoleId, rp.Actions })
.ToListAsync(ct);
var approverRoleIds = grants.Where(g => g.Actions.HasFlag(RolePermissionAction.Approve)).Select(g => g.RoleId).ToHashSet();
var candidates = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active && u.RoleId != null)
.Select(u => new { u.UserId, RoleId = u.RoleId!.Value, RoleCode = u.Role!.Code })
.ToListAsync(ct);
var level2Roles = target.CurrentLevel >= 2 ? Level2Roles(await LoadConfigAsync(ct)) : [];
return candidates
.Where(u => string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase) || approverRoleIds.Contains(u.RoleId))
.Where(u => u.UserId != target.RequestedBy && u.UserId != target.Level1ApprovedBy)
.Where(u => level2Roles.Count == 0 || level2Roles.Contains(u.RoleCode) || string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase))
.Select(u => u.UserId)
.ToList();
}
public async Task NotifyPendingAsync(ApprovalTarget target, string summary, CancellationToken ct = default)
{
var approvers = await ApproverIdsAsync(target, ct);
var step = target.RequiredApprovals > 1 ? $" (approval {target.CurrentLevel} of {target.RequiredApprovals})" : string.Empty;
await _notifications.NotifyUsersAsync(approvers, "approval.pending",
$"Approval needed: {target.DocNo}", $"{summary} is waiting for your approval{step}.", target.DocType, target.DocId, ct);
}
private async Task<ApprovalConfig> LoadConfigAsync(CancellationToken ct)
=> await _config.Query().AsNoTracking().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct)
?? new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
private static HashSet<string> Level2Roles(ApprovalConfig c)
=> (c.Level2RoleCodes ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
private static ApprovalConfigDto Map(ApprovalConfig c) => new(
c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue,
c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent,
Level2Roles(c).ToList(), c.UpdatedAt);
}
@@ -0,0 +1,283 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Till cash taken to the bank (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2). Saving a deposit posts a
/// real GL entry (Debit the bank account / Credit the cash account) before anything is committed —
/// the same call-GL-before-commit pattern as <see cref="ExpensePaymentService"/>, so a GL rejection
/// rolls the deposit back. A deposit may not exceed the branch's cash in hand
/// (<see cref="ICashInHandService"/>); the check runs inside the transaction after locking the
/// branch row, so two cashiers can't both deposit the same cash. Cancelling posts the reversing entry.
/// </summary>
public sealed class BankDepositService : IBankDepositService
{
private const string SourceModule = "BANK_DEPOSIT";
private readonly IRepository<BankDeposit> _deposits;
private readonly IRepository<Branch> _branches;
private readonly ICashInHandService _cash;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly ICurrentBranchContext _currentBranch;
private readonly INumberSequenceService _numbers;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly ErpDbContext _db;
private readonly BusinessDayClock _clock;
public BankDepositService(
IRepository<BankDeposit> deposits, IRepository<Branch> branches, ICashInHandService cash,
IGeneralLedgerService gl, ICurrentUser currentUser, ICurrentBranchContext currentBranch,
INumberSequenceService numbers, IBranchConfigService branchConfig, IUnitOfWork uow,
ErpDbContext db, BusinessDayClock clock)
{
_deposits = deposits;
_branches = branches;
_cash = cash;
_gl = gl;
_currentUser = currentUser;
_currentBranch = currentBranch;
_numbers = numbers;
_branchConfig = branchConfig;
_uow = uow;
_db = db;
_clock = clock;
}
public async Task<BankDepositListDto> ListAsync(PageQuery query, BankDepositListFilter filter, CancellationToken ct = default)
{
IQueryable<BankDeposit> q = _deposits.Query().AsNoTracking().Include(x => x.Branch);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|| (x.DepositSlipNo != null && EF.Functions.Like(x.DepositSlipNo, $"%{term}%"))
|| (x.DepositedBy != null && EF.Functions.Like(x.DepositedBy, $"%{term}%")));
}
if (filter.From is not null) q = q.Where(x => x.BusinessDate >= filter.From);
if (filter.To is not null) q = q.Where(x => x.BusinessDate <= filter.To);
if (filter.BranchId is not null) q = q.Where(x => x.BranchId == filter.BranchId);
if (filter.BankAccountId is not null) q = q.Where(x => x.ToGlBankAccountId == filter.BankAccountId);
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
var total = await q.CountAsync(ct);
var totalAmount = await q.SumAsync(x => x.Amount, ct);
var rows = await q.OrderByDescending(x => x.BusinessDate).ThenByDescending(x => x.BankDepositId)
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
var page = PagedResponse<BankDepositDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
return new BankDepositListDto(page.Items, page.Pagination, totalAmount);
}
public async Task<BankDepositDto?> GetAsync(int bankDepositId, CancellationToken ct = default)
{
var deposit = await _deposits.Query().AsNoTracking().Include(x => x.Branch)
.FirstOrDefaultAsync(x => x.BankDepositId == bankDepositId, ct);
return deposit is null ? null : Map(deposit);
}
public async Task<BankDepositDto> CreateAsync(CreateBankDepositRequest request, CancellationToken ct = default)
{
if (request.Amount <= 0)
throw new DomainException(ErrorCodes.Validation, "The deposit amount must be greater than zero.", 400);
var (branchId, cashScope) = await ResolveBranchAsync(request.BranchId, ct);
var branch = await _branches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BranchId == branchId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Branch {branchId} does not exist.", 422);
var depositDate = ValidateDates(request);
var (cashAccount, bankAccount) = await ResolveAccountsAsync(request, ct);
var deposit = await _uow.ExecuteInTransactionAsync(async token =>
{
await LockBranchAsync(branchId, token);
// Re-read inside the lock: a deposit committed a moment ago by someone else is now visible.
var available = await _cash.GetAvailableToDepositAsync(request.BusinessDate, cashScope, token);
if (request.Amount > available)
throw new DomainException(ErrorCodes.BankDepositExceedsCashInHand,
$"Amount {request.Amount:N2} exceeds the cash in hand available for {request.BusinessDate:yyyy-MM-dd} ({available:N2}).", 400);
var docNo = await _numbers.NextAsync(DocumentTypes.BankDeposit, branchId, token);
var narration = $"Cash deposit {docNo} — {request.BusinessDate:yyyy-MM-dd}";
var period = await _gl.GetPeriodByDateAsync(depositDate, token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = depositDate,
SourceModule = SourceModule,
Reference = docNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(bankAccount.GlAccountCode, request.Amount, 0m, narration),
new GlJournalEntryLineRequest(cashAccount.GlAccountCode, 0m, request.Amount, narration)
]
}, token);
var now = DateTime.UtcNow;
var entity = new BankDeposit
{
DocNo = docNo,
BranchId = branchId,
BusinessDate = request.BusinessDate,
DepositDate = depositDate,
Amount = request.Amount,
FromGlCashAccountId = cashAccount.AccountId,
FromCashAccountCode = cashAccount.GlAccountCode,
FromCashAccountName = cashAccount.AccountName,
ToGlBankAccountId = bankAccount.AccountId,
ToBankAccountCode = bankAccount.GlAccountCode,
ToBankAccountName = bankAccount.AccountName,
ToBankName = bankAccount.BankName,
ToAccountNumber = bankAccount.AccountNumber,
DepositSlipNo = Normalize(request.DepositSlipNo),
DepositedBy = Normalize(request.DepositedBy),
Remarks = Normalize(request.Remarks),
Status = BankDepositStatus.Posted,
GlJournalNo = posted.JournalNo,
GlPostedAt = now,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now
};
await _deposits.AddAsync(entity, token);
return entity;
}, ct);
return Map(deposit, branch.Name);
}
public async Task<BankDepositDto> CancelAsync(int bankDepositId, CancelBankDepositRequest request, CancellationToken ct = default)
{
var deposit = await _deposits.Query().Include(x => x.Branch)
.FirstOrDefaultAsync(x => x.BankDepositId == bankDepositId, ct)
?? throw new NotFoundException($"Bank deposit {bankDepositId} was not found.");
if (deposit.Status != BankDepositStatus.Posted)
throw new DomainException(ErrorCodes.BankDepositNotCancellable, $"Bank deposit {deposit.DocNo} is already {deposit.Status}.", 409);
// Cancelling only puts cash back in hand, so there is no balance to check.
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
var today = _clock.Today;
var narration = $"Cancellation of cash deposit {deposit.DocNo}";
var period = await _gl.GetPeriodByDateAsync(today, token);
var reversal = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = today,
SourceModule = SourceModule,
Reference = deposit.DocNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(deposit.FromCashAccountCode, deposit.Amount, 0m, narration),
new GlJournalEntryLineRequest(deposit.ToBankAccountCode, 0m, deposit.Amount, narration)
]
}, token);
deposit.Status = BankDepositStatus.Cancelled;
deposit.GlReversalJournalNo = reversal.JournalNo;
deposit.CancelReason = request.Reason.Trim();
deposit.CancelledBy = _currentUser.AuditUserId;
deposit.CancelledAt = now;
return 0;
}, ct);
return Map(deposit);
}
/// <summary>
/// The branch the deposit belongs to and the branch scope its cash in hand is measured over. A
/// branch user always deposits for their own branch. With branching off every document lives on
/// the one default branch, so the cash is measured across all of them (scope null).
/// </summary>
private async Task<(int BranchId, int? CashScope)> ResolveBranchAsync(int requested, CancellationToken ct)
{
if (_currentBranch.IsActive && _currentBranch.BranchId is { } own)
return (own, own);
var branchId = await _branchConfig.ResolveBranchIdAsync(requested, ct);
return await _branchConfig.IsBranchingEnabledAsync(ct) ? (branchId, branchId) : (branchId, null);
}
private DateOnly ValidateDates(CreateBankDepositRequest request)
{
var today = _clock.Today;
if (request.BusinessDate > today)
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The business date cannot be in the future.", 400);
if (_cash.TrackingStartDate is { } start && request.BusinessDate < start)
throw new DomainException(ErrorCodes.BankDepositDateInvalid,
$"Cash in hand is tracked from {start:yyyy-MM-dd}; earlier days cannot be deposited.", 400);
var depositDate = request.DepositDate ?? request.BusinessDate;
if (depositDate < request.BusinessDate)
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The deposit date cannot be before the business date.", 400);
// One day of slack, as with payment dates: the client sends its local date.
if (depositDate > today.AddDays(1))
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The deposit date cannot be in the future.", 400);
return depositDate;
}
private async Task<(GlBankAccount Cash, GlBankAccount Bank)> ResolveAccountsAsync(CreateBankDepositRequest request, CancellationToken ct)
{
var accounts = await _gl.ListBankAccountsAsync(ct);
// GL keeps cash and bank accounts in separate tables with their own ids, so cash account 1 and
// bank account 1 can both exist — an id only means something together with its type.
var cash = accounts.FirstOrDefault(a => a.AccountId == request.FromGlCashAccountId && IsType(a, "Cash"))
?? throw new DomainException(ErrorCodes.BankDepositCashAccountInvalid,
$"Cash account {request.FromGlCashAccountId} was not found — deposits must come out of a cash account.", 400);
var bank = accounts.FirstOrDefault(a => a.AccountId == request.ToGlBankAccountId && IsType(a, "Bank"));
if (bank is null)
throw new DomainException(ErrorCodes.BankDepositBankAccountInvalid,
$"Bank account {request.ToGlBankAccountId} was not found — deposits must go into a bank account.", 400);
return (cash, bank);
}
private static bool IsType(GlBankAccount account, string type)
=> string.Equals(account.AccountType, type, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Serializes deposits per branch for the rest of the transaction: the second of two concurrent
/// deposits waits here, then sees the first one's committed row when it re-reads the balance.
/// The in-memory test provider has no row locks (or concurrency), so it is skipped there.
/// </summary>
private async Task LockBranchAsync(int branchId, CancellationToken ct)
{
if (!_db.Database.IsRelational()) return;
await _db.Database.ExecuteSqlInterpolatedAsync(
$"SELECT `BranchId` FROM `hr_branches` WHERE `BranchId` = {branchId} FOR UPDATE", ct);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary><paramref name="branchName"/> stands in when the Branch navigation isn't loaded (just created).</summary>
private static BankDepositDto Map(BankDeposit x) => Map(x, x.Branch?.Name);
private static BankDepositDto Map(BankDeposit x, string? branchName) => new(
x.BankDepositId, x.DocNo, x.BranchId, branchName,
x.BusinessDate, x.DepositDate, x.Amount,
x.FromGlCashAccountId, x.FromCashAccountCode, x.FromCashAccountName,
x.ToGlBankAccountId, x.ToBankAccountCode, x.ToBankAccountName, x.ToBankName, x.ToAccountNumber,
x.DepositSlipNo, x.DepositedBy, x.Remarks,
x.Status, x.GlJournalNo, x.GlPostedAt,
x.GlReversalJournalNo, x.CancelReason, x.CancelledAt,
x.CreatedBy, x.CreatedAt);
}
+13 -4
View File
@@ -178,7 +178,7 @@ public sealed class BundleSaleService : IBundleSaleService
}; };
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct); bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
Recalculate(bundle, request.BundlePrice); Recalculate(bundle, request.BundlePrice, request.CardAmount);
bundle.BundleCode = $"{bundle.BundleNo}-B"; bundle.BundleCode = $"{bundle.BundleNo}-B";
await _bundles.AddAsync(bundle, ct); await _bundles.AddAsync(bundle, ct);
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
@@ -203,7 +203,7 @@ public sealed class BundleSaleService : IBundleSaleService
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines.Clear(); bundle.Lines.Clear();
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line); foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
Recalculate(bundle, request.BundlePrice); Recalculate(bundle, request.BundlePrice, request.CardAmount);
bundle.UpdatedAt = DateTime.UtcNow; bundle.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return Map(bundle); return Map(bundle);
@@ -273,7 +273,8 @@ public sealed class BundleSaleService : IBundleSaleService
return lines; return lines;
} }
private static void Recalculate(BundleSale bundle, decimal bundlePrice) /// <summary>A bundle sale is always fully paid; <paramref name="cardAmount"/> null means all cash.</summary>
private static void Recalculate(BundleSale bundle, decimal bundlePrice, decimal? cardAmount)
{ {
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal); bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
bundle.BundlePrice = bundlePrice; bundle.BundlePrice = bundlePrice;
@@ -281,6 +282,13 @@ public sealed class BundleSaleService : IBundleSaleService
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice); bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
bundle.TaxTotal = 0m; bundle.TaxTotal = 0m;
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal; bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
var card = cardAmount ?? 0m;
if (card < 0 || card > bundle.GrandTotal)
throw new DomainException(ErrorCodes.SalesCardAmountInvalid,
$"cardAmount must be between 0 and the bundle's total {bundle.GrandTotal}.", 400);
bundle.CardAmount = card;
bundle.CashAmount = bundle.GrandTotal - card;
} }
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new( private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
@@ -290,6 +298,7 @@ public sealed class BundleSaleService : IBundleSaleService
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId, x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
x.CreatedAt, x.UpdatedAt, x.CreatedAt, x.UpdatedAt,
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList()); x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList(),
x.CashAmount, x.CardAmount);
} }
@@ -0,0 +1,34 @@
namespace ERPCore.Services;
/// <summary>
/// The business calendar day shared by the Day-End Sales Report and the Bank Deposit module
/// (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.1). Sales documents are stamped in UTC, so a business day
/// is the <c>DayEndReport:TimeZone</c> calendar day converted to a UTC <c>[start, end)</c> window.
/// One instance per app — the zone is resolved once from configuration.
/// </summary>
public sealed class BusinessDayClock
{
public BusinessDayClock(IConfiguration configuration)
=> Zone = ResolveZone(configuration["DayEndReport:TimeZone"]);
public TimeZoneInfo Zone { get; }
/// <summary>Today in the business time zone.</summary>
public DateOnly Today => LocalDay(DateTime.UtcNow);
/// <summary>The UTC instant the local day starts at.</summary>
public DateTime ToUtc(DateOnly localDay)
=> TimeZoneInfo.ConvertTimeToUtc(localDay.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified), Zone);
/// <summary>The local business day a UTC timestamp falls on.</summary>
public DateOnly LocalDay(DateTime utc)
=> DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), Zone));
private static TimeZoneInfo ResolveZone(string? id)
{
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
catch (TimeZoneNotFoundException) { return TimeZoneInfo.Utc; }
catch (InvalidTimeZoneException) { return TimeZoneInfo.Utc; }
}
}
@@ -0,0 +1,196 @@
using System.Globalization;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Till cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §7.1). Only cash counts: slip and bundle
/// <c>CashAmount</c> and cash invoice payments come in, cash expense payments go out, and Posted bank
/// deposits take it to the bank. The balance carries from day to day starting at
/// <c>BankDeposit:CashTrackingStartDate</c>; the opening balance before a window is a handful of
/// database sums, and the days inside it are bucketed by local day here (same reason as
/// <see cref="DayEndReportService"/>: MariaDB can't portably group by a zone-shifted date).
/// </summary>
public sealed class CashInHandService : ICashInHandService
{
private const int MaxRangeDays = 92;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IRepository<ExpensePayment> _expensePayments;
private readonly IRepository<BankDeposit> _deposits;
private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock;
private readonly string? _defaultCashAccountCode;
public CashInHandService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoicePayment> invoicePayments,
IRepository<ExpensePayment> expensePayments, IRepository<BankDeposit> deposits,
ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration)
{
_slips = slips;
_bundles = bundles;
_invoicePayments = invoicePayments;
_expensePayments = expensePayments;
_deposits = deposits;
_currentBranch = currentBranch;
_clock = clock;
_defaultCashAccountCode = configuration["BankDeposit:DefaultCashAccountCode"];
TrackingStartDate = DateOnly.TryParse(configuration["BankDeposit:CashTrackingStartDate"], CultureInfo.InvariantCulture, out var start)
? start
: null;
}
public DateOnly? TrackingStartDate { get; }
public async Task<CashInHandDto> GetPositionAsync(DateOnly? day, int? branchId, CancellationToken ct = default)
{
var date = day ?? _clock.Today;
var branch = EffectiveBranch(branchId);
var today = _clock.Today;
// One pass from the day to today serves both the day's own figures and what is still depositable.
var days = await ComputeAsync(date, date > today ? date : today, branch, ct);
return new CashInHandDto(days[0], Available(days, date, today), branch, TrackingStartDate, _clock.Zone.Id, _defaultCashAccountCode);
}
public async Task<CashInHandDailyDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
{
if (to < from)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, "'to' must be on or after 'from'.", 400);
if (to.DayNumber - from.DayNumber + 1 > MaxRangeDays)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, $"The range can be at most {MaxRangeDays} days.", 400);
var branch = EffectiveBranch(branchId);
var days = await ComputeAsync(from, to, branch, ct);
return new CashInHandDailyDto(from, to, days, branch, TrackingStartDate, _clock.Zone.Id);
}
public async Task<IReadOnlyList<CashPositionDto>> GetRangeAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
=> await ComputeAsync(from, to, EffectiveBranch(branchId), ct);
public async Task<decimal> GetAvailableToDepositAsync(DateOnly day, int? branchScope, CancellationToken ct = default)
{
var today = _clock.Today;
if (day > today || !IsTracked(day)) return 0m;
return Available(await ComputeAsync(day, today, branchScope, ct), day, today);
}
/// <summary>A branch-scoped (non-admin) caller always sees only their own branch, whatever they ask for.</summary>
private int? EffectiveBranch(int? requested) => _currentBranch.IsActive ? _currentBranch.BranchId : requested;
private bool IsTracked(DateOnly day) => TrackingStartDate is not { } start || day >= start;
/// <summary>Lowest closing balance from <paramref name="day"/> to today: depositing more would overdraw a later day.</summary>
private decimal Available(IReadOnlyList<CashPositionDto> days, DateOnly day, DateOnly today)
{
if (day > today || !IsTracked(day)) return 0m;
var min = days.Where(d => d.Date >= day && d.Date <= today).Min(d => d.Closing);
return Math.Max(0m, min);
}
private async Task<List<CashPositionDto>> ComputeAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
{
// Opening: everything tracked before the window — [start, from).
var opening = 0m;
if (TrackingStartDate is not { } trackStart || trackStart < from)
{
DateTime? lo = TrackingStartDate is { } s ? _clock.ToUtc(s) : null;
var hi = _clock.ToUtc(from);
var cashIn = await Slips(lo, hi, branch).SumAsync(s => s.CashAmount, ct)
+ await Bundles(lo, hi, branch).SumAsync(b => b.CashAmount, ct)
+ await InvoicePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
var cashOut = await ExpensePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
var deposited = await Deposits(TrackingStartDate, from, branch).SumAsync(d => d.Amount, ct);
opening = cashIn - cashOut - deposited;
}
// The window itself, bucketed by local day.
var start = _clock.ToUtc(from);
var end = _clock.ToUtc(to.AddDays(1));
var inRows = new List<(DateTime At, decimal Amount)>();
inRows.AddRange((await Slips(start, end, branch).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct))
.Select(r => (r.SlipDate, r.CashAmount)));
inRows.AddRange((await Bundles(start, end, branch).Select(b => new { b.BundleDate, b.CashAmount }).ToListAsync(ct))
.Select(r => (r.BundleDate, r.CashAmount)));
inRows.AddRange((await InvoicePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct))
.Select(r => (r.PaymentDate, r.Amount)));
var outRows = await ExpensePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct);
var depositRows = await Deposits(from, to.AddDays(1), branch).Select(d => new { d.BusinessDate, d.Amount }).ToListAsync(ct);
var inByDay = inRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
var outByDay = outRows.GroupBy(r => _clock.LocalDay(r.PaymentDate)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
var depositByDay = depositRows.GroupBy(r => r.BusinessDate).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
var days = new List<CashPositionDto>(to.DayNumber - from.DayNumber + 1);
var running = opening;
for (var day = from; day <= to; day = day.AddDays(1))
{
var cashIn = inByDay.GetValueOrDefault(day);
var cashOut = outByDay.GetValueOrDefault(day);
var deposited = depositByDay.GetValueOrDefault(day);
var net = cashIn - cashOut;
// Before tracking starts nothing carries in or out; the day stands alone.
var tracked = IsTracked(day);
var open = tracked ? running : 0m;
var close = open + net - deposited;
if (tracked) running = close;
days.Add(new CashPositionDto(day, open, cashIn, cashOut, net, deposited, close));
}
return days;
}
// --- Sources. lo is inclusive (null = no lower bound), hi exclusive. ---
private IQueryable<SalesSlip> Slips(DateTime? lo, DateTime hi, int? branch)
{
var q = _slips.Query().AsNoTracking()
.Where(s => s.Status == SalesSlipStatus.Posted && s.CashAmount != 0 && s.SlipDate < hi);
if (lo is not null) q = q.Where(s => s.SlipDate >= lo);
return branch is null ? q : q.Where(s => s.Warehouse!.BranchId == branch);
}
private IQueryable<BundleSale> Bundles(DateTime? lo, DateTime hi, int? branch)
{
var q = _bundles.Query().AsNoTracking()
.Where(b => b.Status == BundleSaleStatus.Posted && b.CashAmount != 0 && b.BundleDate < hi);
if (lo is not null) q = q.Where(b => b.BundleDate >= lo);
return branch is null ? q : q.Where(b => b.Warehouse!.BranchId == branch);
}
/// <summary>Cash received against Posted invoices — whatever day the invoice itself is from.</summary>
private IQueryable<SalesInvoicePayment> InvoicePayments(DateTime? lo, DateTime hi, int? branch)
{
var q = _invoicePayments.Query().AsNoTracking()
.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash && p.SalesInvoice!.Status == SalesInvoiceStatus.Posted && p.PaymentDate < hi);
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
return branch is null ? q : q.Where(p => p.SalesInvoice!.BranchId == branch);
}
private IQueryable<ExpensePayment> ExpensePayments(DateTime? lo, DateTime hi, int? branch)
{
var q = _expensePayments.Query().AsNoTracking()
.Where(p => p.PaymentMethod == ExpensePaymentMethod.Cash && p.Expense!.Status != ExpenseStatus.Cancelled && p.PaymentDate < hi);
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
return branch is null ? q : q.Where(p => p.Expense!.BranchId == branch);
}
/// <summary>Posted deposits by business date; <paramref name="lo"/> inclusive (null = no bound), <paramref name="hi"/> exclusive.</summary>
private IQueryable<BankDeposit> Deposits(DateOnly? lo, DateOnly hi, int? branch)
{
var q = _deposits.Query().AsNoTracking()
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate < hi);
if (lo is not null) q = q.Where(d => d.BusinessDate >= lo);
return branch is null ? q : q.Where(d => d.BranchId == branch);
}
}
+23 -53
View File
@@ -16,53 +16,47 @@ namespace ERPCore.Services;
/// <summary> /// <summary>
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable, /// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the /// 02-SECURITY C.7); posting closes the count and hands its variances to
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7) /// <see cref="AdjustmentService"/> as one adjustment (a variance is an adjustment in disguise,
/// and closes the count — all in one UoW transaction. GL posting for that variance /// C.7). That adjustment follows the same approval rules as any other
/// mirrors <see cref="AdjustmentService"/>'s (same account config, same gain/loss /// (docs/DUAL_APPROVAL_PLAN.md §8, decision 3) — a count is not a way around approval — and
/// split) since it's the exact same kind of document under the hood. /// moves stock when it is approved, or at once when approvals are switched off.
/// </summary> /// </summary>
public sealed class CountService : ICountService public sealed class CountService : ICountService
{ {
private const string VarianceReasonCode = "VAR"; private const string VarianceReasonCode = "VAR";
private readonly IRepository<StockCount> _counts; private readonly IRepository<StockCount> _counts;
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items; private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes; private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly IRepository<StockLayer> _stockLayers; private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<Batch> _batches; private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials; private readonly IRepository<Serial> _serials;
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
public CountService( public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<StockCount> counts, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator, IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches, IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, IGlAccountConfigService glAccounts) IRepository<Serial> serials, IGlAccountConfigService glAccounts)
{ {
_serialTracking = serialTracking; _serialTracking = serialTracking;
_counts = counts; _counts = counts;
_adjustments = adjustments;
_warehouses = warehouses; _warehouses = warehouses;
_items = items; _items = items;
_reasonCodes = reasonCodes; _reasonCodes = reasonCodes;
_fifo = fifo; _fifo = fifo;
_mutator = mutator;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig; _branchConfig = branchConfig;
_uow = uow; _uow = uow;
_gl = gl;
_stockLayers = stockLayers; _stockLayers = stockLayers;
_batches = batches; _batches = batches;
_serials = serials; _serials = serials;
@@ -266,13 +260,9 @@ public sealed class CountService : ICountService
// Batch and serial lines adjust exactly their own lot/unit. // Batch and serial lines adjust exactly their own lot/unit.
var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList(); var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList();
var deltas = varianceLines
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.Variance!.Value,
l.SerialId is int serialId ? new List<int> { serialId } : null))
.ToList();
// No variances → just close the count, no adjustment. // No variances → just close the count, no adjustment.
if (deltas.Count == 0) if (varianceLines.Count == 0)
{ {
count.Status = CountStatus.Posted; count.Status = CountStatus.Posted;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
@@ -283,53 +273,33 @@ public sealed class CountService : ICountService
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct) .FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
var now = DateTime.UtcNow; // A serial counted missing is written off (Scrapped); one found is brought back InStock —
var (adjustmentId, glJournalNo, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token => // the adjustment's own serial rules, applied when it posts.
var request = new CreateAdjustmentRequest
{ {
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var adjustment = new StockAdjustment
{
DocNo = docNo,
WarehouseId = count.WarehouseId, WarehouseId = count.WarehouseId,
BranchId = count.BranchId, BranchId = count.BranchId,
ReasonCodeId = reason.ReasonCodeId, ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted, Lines = varianceLines.Select(l => new CreateAdjustmentLineInput
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = deltas.Select(d => new StockAdjustmentLine
{ {
ItemId = d.ItemId, ItemId = l.ItemId,
BinId = d.BinId, BinId = l.BinId,
BatchId = d.BatchId, BatchId = l.BatchId,
SerialId = d.SerialIds?.FirstOrDefault(), QtyDelta = l.Variance!.Value,
QtyDelta = d.QtyDelta SerialIds = l.SerialId is int missing && l.Variance < 0 ? [missing] : null,
SerialNos = l.Serial is not null && l.Variance > 0 ? [l.Serial.SerialNo] : null
}).ToList() }).ToList()
}; };
await _adjustments.AddAsync(adjustment, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token); var (adjustment, refs) = await _adjustments.SubmitAsync(request, _ =>
// A serial counted missing is written off (Scrapped); one found keeps/regains InStock here.
foreach (var line in varianceLines.Where(l => l.SerialId is not null))
{ {
var serial = await _serials.Query().FirstAsync(s => s.SerialId == line.SerialId, token);
var found = line.Variance > 0;
await _serialTracking.MoveAsync(new[] { serial },
found ? SerialStatus.InStock : SerialStatus.Scrapped,
found ? count.WarehouseId : null, found ? line.BinId : null,
DocumentTypes.Count, count.CountId, now, token);
}
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
count.Status = CountStatus.Posted; count.Status = CountStatus.Posted;
return (adjustment.AdjustmentId, adjustment.GlJournalNo, refs); return Task.CompletedTask;
}, ct); }, ct);
// Map ledger ids after commit so they are populated. return new CountPostResultDto(
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, glJournalNo, ledgerEntries.Select(r => r.LedgerId).ToList()); count.CountId, count.Status, adjustment.AdjustmentId, adjustment.GlJournalNo, refs.Select(r => r.LedgerId).ToList(),
adjustment.Status, adjustment.DocNo);
} }
private static CountDto Map(StockCount c) => new( private static CountDto Map(StockCount c) => new(
+40 -2
View File
@@ -17,6 +17,8 @@ public sealed class CustomerService : ICustomerService
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly IRepository<SalesConfig> _salesConfig; private readonly IRepository<SalesConfig> _salesConfig;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary> /// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
@@ -24,12 +26,15 @@ public sealed class CustomerService : ICustomerService
public CustomerService( public CustomerService(
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
IRepository<SalesConfig> salesConfig, IUnitOfWork uow) IRepository<SalesConfig> salesConfig, IRepository<SalesInvoice> invoices,
IRepository<SalesInvoicePayment> invoicePayments, IUnitOfWork uow)
{ {
_customers = customers; _customers = customers;
_warehouses = warehouses; _warehouses = warehouses;
_branchConfig = branchConfig; _branchConfig = branchConfig;
_salesConfig = salesConfig; _salesConfig = salesConfig;
_invoices = invoices;
_invoicePayments = invoicePayments;
_uow = uow; _uow = uow;
} }
@@ -70,7 +75,40 @@ public sealed class CustomerService : ICustomerService
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt)) c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<CustomerDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<CustomerDto>.Create(await WithReceivablesAsync(rows, ct), query.Page, query.PageSize, total);
}
/// <summary>
/// Adds each customer's receivable position — the unpaid balance and count of their Posted sales
/// invoices, and their latest invoice payment — for just the customers on this page.
/// </summary>
private async Task<List<CustomerDto>> WithReceivablesAsync(List<CustomerDto> rows, CancellationToken ct)
{
if (rows.Count == 0) return rows;
var ids = rows.Select(r => r.CustomerId).ToList();
var dues = await _invoices.Query().AsNoTracking()
.Where(i => ids.Contains(i.CustomerId) && i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0)
.GroupBy(i => i.CustomerId)
.Select(g => new { CustomerId = g.Key, Amount = g.Sum(i => i.BalanceAmount), Count = g.Count() })
.ToDictionaryAsync(x => x.CustomerId, ct);
var lastPaid = await _invoicePayments.Query().AsNoTracking()
.Where(p => ids.Contains(p.SalesInvoice!.CustomerId))
.GroupBy(p => p.SalesInvoice!.CustomerId)
.Select(g => new { CustomerId = g.Key, Last = g.Max(p => p.PaymentDate) })
.ToDictionaryAsync(x => x.CustomerId, x => x.Last, ct);
return rows.Select(r =>
{
var due = dues.GetValueOrDefault(r.CustomerId);
return r with
{
OutstandingAmount = due?.Amount ?? 0m,
OutstandingInvoiceCount = due?.Count ?? 0,
LastPaymentDate = lastPaid.TryGetValue(r.CustomerId, out var last) ? last : null
};
}).ToList();
} }
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default) public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
@@ -0,0 +1,284 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Reports;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Sales documents are stamped in
/// UTC, so each business day is the <see cref="BusinessDayClock"/> calendar day converted to a UTC
/// <c>[start, end)</c> window. The database does the filtering and returns slim projections; the
/// rows are bucketed into local days here, since MariaDB can't group by a time-zone-shifted date
/// portably and a ≤ 92-day window of slim rows is small. Cost of goods sold is the stock ledger's
/// Out value per sale document — the same source <see cref="SalesDayEndService"/> posts COGS from.
/// The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's
/// <see cref="ICashInHandService"/> (docs/BANK_DEPOSIT_MODULE_PLAN.md §9); this report owns none of them.
/// </summary>
public sealed class DayEndReportService : IDayEndReportService
{
private const int MaxRangeDays = 92;
private const int IdChunk = 1000;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Expense> _expenses;
private readonly IRepository<BankDeposit> _deposits;
private readonly ICashInHandService _cash;
private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock;
public DayEndReportService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoice> invoices,
IRepository<SalesInvoicePayment> invoicePayments, IRepository<StockLedger> ledger,
IRepository<Expense> expenses, IRepository<BankDeposit> deposits, ICashInHandService cash,
ICurrentBranchContext currentBranch, BusinessDayClock clock)
{
_slips = slips;
_bundles = bundles;
_invoices = invoices;
_invoicePayments = invoicePayments;
_ledger = ledger;
_expenses = expenses;
_deposits = deposits;
_cash = cash;
_currentBranch = currentBranch;
_clock = clock;
}
public async Task<DayEndReportDto> GetDayAsync(DateOnly? requestedDate, int? branchId, CancellationToken ct = default)
{
var date = requestedDate ?? _clock.Today;
var branch = EffectiveBranch(branchId);
var data = await LoadAsync(date, date, branch, ct);
var sales = data.Sales
.OrderBy(s => s.At)
.Select(s => new DayEndReportSaleDto(
s.DocType, s.DocId, s.DocNo, s.At, s.Customer, s.Total, s.Paid, s.Total - s.Paid, s.PaidBy, s.Cost, s.Total - s.Cost))
.ToList();
var expenses = await PostedExpenses(date, date, branch)
.OrderBy(e => e.ExpenseId)
.Select(e => new DayEndReportExpenseDto(
e.ExpenseId, e.DocNo, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount))
.ToListAsync(ct);
var depositsQuery = _deposits.Query().AsNoTracking()
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate == date);
if (branch is not null) depositsQuery = depositsQuery.Where(d => d.BranchId == branch);
var deposits = await depositsQuery
.OrderBy(d => d.BankDepositId)
.Select(d => new DayEndReportDepositDto(
d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount))
.ToListAsync(ct);
return new DayEndReportDto(data.Days[0], sales, expenses, deposits, branch, _clock.Zone.Id);
}
public async Task<DayEndDailyReportDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
{
if (to < from)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, "'to' must be on or after 'from'.", 400);
if (to.DayNumber - from.DayNumber + 1 > MaxRangeDays)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, $"The range can be at most {MaxRangeDays} days.", 400);
var branch = EffectiveBranch(branchId);
var data = await LoadAsync(from, to, branch, ct);
return new DayEndDailyReportDto(from, to, data.Days, Sum(data.Days), branch, _clock.Zone.Id);
}
/// <summary>A branch-scoped (non-admin) caller always sees only their own branch, whatever they ask for.</summary>
private int? EffectiveBranch(int? requested) => _currentBranch.IsActive ? _currentBranch.BranchId : requested;
/// <summary>One sale, whatever document it is. Cash/Card/Other are how its Paid amount was paid.</summary>
private sealed record Sale(
string DocType, int DocId, string DocNo, DateTime At, DateOnly Day, string Customer,
decimal Total, decimal Paid, decimal Cash, decimal Card, decimal Other, string PaidBy, decimal Cost);
private sealed record Loaded(List<DayEndReportDayDto> Days, List<Sale> Sales);
private async Task<Loaded> LoadAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
{
var start = _clock.ToUtc(from);
var end = _clock.ToUtc(to.AddDays(1));
var slipsQuery = _slips.Query().AsNoTracking()
.Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end);
if (branch is not null) slipsQuery = slipsQuery.Where(s => s.Warehouse!.BranchId == branch);
var slips = await slipsQuery
.Select(s => new { s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount })
.ToListAsync(ct);
var bundlesQuery = _bundles.Query().AsNoTracking()
.Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end);
if (branch is not null) bundlesQuery = bundlesQuery.Where(b => b.Warehouse!.BranchId == branch);
var bundles = await bundlesQuery
.Select(b => new { b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount })
.ToListAsync(ct);
var invoicesQuery = _invoices.Query().AsNoTracking()
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= start && i.InvoiceDate < end);
if (branch is not null) invoicesQuery = invoicesQuery.Where(i => i.BranchId == branch);
var invoices = await invoicesQuery
.Select(i => new { i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, i.CustomerSnapshotName, i.NetPayable, i.PaidAmount })
.ToListAsync(ct);
// Every payment that matters to the window: those against its invoices (the method split of
// Paid), and those received inside it against earlier invoices (collections / net cash).
var paymentsQuery = _invoicePayments.Query().AsNoTracking()
.Where(p => p.SalesInvoice!.Status == SalesInvoiceStatus.Posted
&& ((p.SalesInvoice.InvoiceDate >= start && p.SalesInvoice.InvoiceDate < end)
|| (p.PaymentDate >= start && p.PaymentDate < end)));
if (branch is not null) paymentsQuery = paymentsQuery.Where(p => p.SalesInvoice!.BranchId == branch);
var payments = await paymentsQuery
.Select(p => new { p.SalesInvoiceId, InvoiceDate = p.SalesInvoice!.InvoiceDate, p.PaymentDate, p.PaymentMethod, p.Amount })
.ToListAsync(ct);
var slipCost = await CostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct);
var bundleCost = await CostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct);
var invoiceCost = await CostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct);
var expenses = await PostedExpenses(from, to, branch)
.Select(e => new { e.ExpenseDate, e.Amount })
.ToListAsync(ct);
// Branch is already the effective one, so the cash service scopes it the same way.
var cashByDay = (await _cash.GetRangeAsync(from, to, branch, ct)).ToDictionary(c => c.Date);
var paymentsByInvoice = payments.GroupBy(p => p.SalesInvoiceId).ToDictionary(g => g.Key, g => g.ToList());
var sales = new List<Sale>(slips.Count + bundles.Count + invoices.Count);
sales.AddRange(slips.Select(s => new Sale(
"Slip", s.SalesSlipId, s.SlipNo, s.SlipDate, LocalDay(s.SlipDate), s.CustomerSnapshotName,
s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount, 0m, PaidBy(s.CashAmount, s.CardAmount, 0m, null),
slipCost.GetValueOrDefault(s.SalesSlipId))));
sales.AddRange(bundles.Select(b => new Sale(
"Bundle", b.BundleSaleId, b.BundleNo, b.BundleDate, LocalDay(b.BundleDate), b.CustomerSnapshotName,
b.GrandTotal, b.GrandTotal, b.CashAmount, b.CardAmount, 0m, PaidBy(b.CashAmount, b.CardAmount, 0m, null),
bundleCost.GetValueOrDefault(b.BundleSaleId))));
foreach (var i in invoices)
{
var own = paymentsByInvoice.GetValueOrDefault(i.SalesInvoiceId) ?? [];
var cash = own.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash).Sum(p => p.Amount);
var card = own.Where(p => p.PaymentMethod == SalesPaymentMethod.Card).Sum(p => p.Amount);
var others = own.Where(p => p.PaymentMethod is not (SalesPaymentMethod.Cash or SalesPaymentMethod.Card)).ToList();
var other = others.Sum(p => p.Amount);
var otherMethods = others.Select(p => p.PaymentMethod).Distinct().ToList();
sales.Add(new Sale(
"Invoice", i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, LocalDay(i.InvoiceDate), i.CustomerSnapshotName,
i.NetPayable, i.PaidAmount, cash, card, other,
PaidBy(cash, card, other, otherMethods.Count == 1 ? otherMethods[0] : null),
invoiceCost.GetValueOrDefault(i.SalesInvoiceId)));
}
var days = new List<DayEndReportDayDto>(to.DayNumber - from.DayNumber + 1);
for (var day = from; day <= to; day = day.AddDays(1))
{
var d = day;
var acc = new DayAccumulator();
foreach (var s in sales.Where(s => s.Day == d)) acc.AddSale(s);
var receivedToday = payments.Where(p => LocalDay(p.PaymentDate) == d).ToList();
acc.EarlierCollections = receivedToday.Where(p => LocalDay(p.InvoiceDate) < d).Sum(p => p.Amount);
acc.ExpenseTotal = expenses.Where(e => e.ExpenseDate == d).Sum(e => e.Amount);
acc.Cash = cashByDay[d];
days.Add(acc.ToDto(d));
}
return new Loaded(days, sales);
}
/// <summary>Sum of the stock ledger's Out value per document — the FIFO cost the sale consumed.</summary>
private async Task<Dictionary<int, decimal>> CostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct)
{
var result = new Dictionary<int, decimal>();
foreach (var chunk in docIds.Distinct().Chunk(IdChunk))
{
var rows = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == docType && l.Direction == Direction.Out && chunk.Contains(l.SourceDocId))
.GroupBy(l => l.SourceDocId)
.Select(g => new { DocId = g.Key, Cost = g.Sum(l => l.Value) })
.ToListAsync(ct);
foreach (var r in rows) result[r.DocId] = r.Cost;
}
return result;
}
private static string PaidBy(decimal cash, decimal card, decimal other, SalesPaymentMethod? singleOther)
{
var ways = (cash > 0 ? 1 : 0) + (card > 0 ? 1 : 0) + (other > 0 ? 1 : 0);
if (ways == 0) return "Unpaid";
if (ways > 1 || (other > 0 && singleOther is null)) return "Mixed";
if (cash > 0) return "Cash";
if (card > 0) return "Card";
return singleOther == SalesPaymentMethod.Cheque ? "Cheque" : "Bank transfer";
}
private IQueryable<Expense> PostedExpenses(DateOnly from, DateOnly to, int? branch)
{
var q = _expenses.Query().AsNoTracking()
.Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= from && e.ExpenseDate <= to);
return branch is null ? q : q.Where(e => e.BranchId == branch);
}
private DateOnly LocalDay(DateTime utc) => _clock.LocalDay(utc);
/// <summary>Flows are summed; cash in hand is a balance, so the range opens with its first day and closes with its last.</summary>
private static DayEndReportDayDto Sum(IReadOnlyList<DayEndReportDayDto> days) => new(
null,
days.Sum(d => d.TotalSales), days.Sum(d => d.PaidAmount), days.Sum(d => d.DueAmount),
days.Sum(d => d.PaidByCash), days.Sum(d => d.PaidByCard), days.Sum(d => d.PaidByOther),
days.Sum(d => d.SalesCount), days.Sum(d => d.CashSalesCount), days.Sum(d => d.CardSalesCount),
days.Sum(d => d.MixedSalesCount), days.Sum(d => d.OtherSalesCount), days.Sum(d => d.UnpaidSalesCount),
days.Sum(d => d.ExpenseTotal), days.Sum(d => d.Income),
days.Sum(d => d.CostOfGoodsSold), days.Sum(d => d.GrossProfit), days.Sum(d => d.NetProfit),
days.Sum(d => d.EarlierInvoiceCollections), days.Sum(d => d.NetCash),
days[0].OpeningCashInHand, days.Sum(d => d.CashDeposited), days[^1].ClosingCashInHand);
private sealed class DayAccumulator
{
private decimal _total, _paid, _cash, _card, _other, _cost;
private int _count, _cashCount, _cardCount, _mixedCount, _otherCount, _unpaidCount;
public decimal ExpenseTotal { get; set; }
public decimal EarlierCollections { get; set; }
public CashPositionDto? Cash { get; set; }
/// <summary>A sale counts as a cash/card/other sale when that is the only way it was paid,
/// mixed when paid more than one way, unpaid when nothing has been paid yet.</summary>
public void AddSale(Sale s)
{
_total += s.Total;
_paid += s.Paid;
_cash += s.Cash;
_card += s.Card;
_other += s.Other;
_cost += s.Cost;
_count++;
var ways = (s.Cash > 0 ? 1 : 0) + (s.Card > 0 ? 1 : 0) + (s.Other > 0 ? 1 : 0);
if (ways == 0) _unpaidCount++;
else if (ways > 1) _mixedCount++;
else if (s.Cash > 0) _cashCount++;
else if (s.Card > 0) _cardCount++;
else _otherCount++;
}
public DayEndReportDayDto ToDto(DateOnly date) => new(
date, _total, _paid, _total - _paid, _cash, _card, _other,
_count, _cashCount, _cardCount, _mixedCount, _otherCount, _unpaidCount,
ExpenseTotal, _total - ExpenseTotal,
_cost, _total - _cost, _total - _cost - ExpenseTotal,
EarlierCollections,
Cash?.NetCash ?? 0m, Cash?.Opening ?? 0m, Cash?.Deposited ?? 0m, Cash?.Closing ?? 0m);
}
}
@@ -0,0 +1,189 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Payments against a Posted expense (docs/EXPENSE_MODULE_PLAN.md §4). One submission may
/// split across several cash/bank accounts; it posts a single GL journal entry (Debit Accrued
/// Expenses / Credit each account) and records one <see cref="ExpensePayment"/> row per line,
/// all sharing that journal number. Same call-GL-before-commit pattern as
/// <see cref="PurchaseInvoicePaymentService"/>, so a rejected/unreachable GL post rolls the
/// whole payment back. Post &amp; Pay runs the accrual and a full or partial payment in one transaction.
/// </summary>
public sealed class ExpensePaymentService : IExpensePaymentService
{
private readonly IRepository<Expense> _expenses;
private readonly IRepository<ExpensePayment> _payments;
private readonly ExpenseService _expenseService;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccruedExpensesCode;
public ExpensePaymentService(
IRepository<Expense> expenses, IRepository<ExpensePayment> payments, ExpenseService expenseService,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_expenses = expenses;
_payments = payments;
_expenseService = expenseService;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
}
public async Task<IReadOnlyList<ExpensePaymentDto>> PayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default)
{
var expense = await _expenseService.LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.Status != ExpenseStatus.Posted || expense.BalanceAmount <= 0)
throw new DomainException(ErrorCodes.ExpenseNotPayable,
$"Expense {expense.DocNo} must be posted with a balance outstanding before it can be paid.", 409);
var total = ValidateRequest(request);
if (total > expense.BalanceAmount)
throw new DomainException(ErrorCodes.ExpensePaymentExceedsBalance,
$"Payment total {total} exceeds the remaining balance {expense.BalanceAmount}.", 400);
var accounts = await ResolveAccountsAsync(request, ct);
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, DateTime.UtcNow);
var recorded = await _uow.ExecuteInTransactionAsync(
token => RecordPaymentAsync(expense, request, accounts, paymentDay, paymentDate, token), ct);
return recorded.Select(ExpenseService.MapPayment).ToList();
}
public async Task<ExpenseDto> PostAndPayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default)
{
var expense = await _expenseService.LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.Status != ExpenseStatus.Draft)
throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status}; use the payments endpoint instead.", 409);
// Full or partial: whatever is left stays as the balance, payable later like any Posted expense.
var total = ValidateRequest(request);
if (total > expense.Amount)
throw new DomainException(ErrorCodes.ExpensePaymentExceedsBalance,
$"Payment total {total} exceeds the expense amount {expense.Amount}.", 400);
var accounts = await ResolveAccountsAsync(request, ct);
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, DateTime.UtcNow);
await _uow.ExecuteInTransactionAsync(async token =>
{
await _expenseService.PostInCurrentTransactionAsync(expense, token);
return await RecordPaymentAsync(expense, request, accounts, paymentDay, paymentDate, token);
}, ct);
return ExpenseService.Map(expense);
}
public async Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default)
{
if (!await _expenses.Query().AnyAsync(x => x.ExpenseId == expenseId, ct))
throw new NotFoundException($"Expense {expenseId} was not found.");
var payments = await _payments.Query().AsNoTracking()
.Where(p => p.ExpenseId == expenseId)
.OrderByDescending(p => p.ExpensePaymentId)
.ToListAsync(ct);
return payments.Select(ExpenseService.MapPayment).ToList();
}
/// <summary>Posts the settlement journal and records the payment rows. Runs inside the caller's transaction.</summary>
private async Task<List<ExpensePayment>> RecordPaymentAsync(
Expense expense, CreateExpensePaymentRequest request, IReadOnlyDictionary<long, GlBankAccount> accounts,
DateOnly paymentDay, DateTime paymentDate, CancellationToken ct)
{
var total = request.Lines.Sum(l => l.Amount);
var narration = $"Payment of expense {expense.DocNo}";
var glLines = new List<GlJournalEntryLineRequest> { new(_glAccruedExpensesCode, total, 0m, narration) };
glLines.AddRange(request.Lines.Select(l => new GlJournalEntryLineRequest(accounts[l.GlBankAccountId].GlAccountCode, 0m, l.Amount, narration)));
var period = await _gl.GetPeriodByDateAsync(paymentDay, ct);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = paymentDay,
SourceModule = "EXPENSE_PAYMENT",
Reference = expense.DocNo,
Narration = narration,
Lines = glLines
}, ct);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var chequeNo = request.PaymentMethod == ExpensePaymentMethod.Cheque ? request.ChequeNo?.Trim() : null;
var reference = string.IsNullOrWhiteSpace(request.Reference) ? null : request.Reference.Trim();
var rows = request.Lines.Select(l => new ExpensePayment
{
ExpenseId = expense.ExpenseId,
Amount = l.Amount,
PaymentDate = paymentDate,
PaymentMethod = request.PaymentMethod,
GlBankAccountId = l.GlBankAccountId,
BankAccountName = accounts[l.GlBankAccountId].AccountName,
ChequeNo = chequeNo,
Reference = reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
}).ToList();
foreach (var row in rows) expense.Payments.Add(row);
expense.PaidAmount += total;
expense.BalanceAmount -= total;
expense.PaymentStatus = ExpenseService.DerivePaymentStatus(expense.PaidAmount, expense.BalanceAmount);
expense.UpdatedAt = now;
return rows;
}
private static decimal ValidateRequest(CreateExpensePaymentRequest request)
{
if (request.Lines.Count == 0)
throw new DomainException(ErrorCodes.Validation, "Add at least one payment line.", 400);
if (request.Lines.Any(l => l.Amount <= 0))
throw new DomainException(ErrorCodes.Validation, "Every payment line needs an amount greater than zero.", 400);
if (request.PaymentMethod == ExpensePaymentMethod.Cheque && string.IsNullOrWhiteSpace(request.ChequeNo))
throw new DomainException(ErrorCodes.ExpenseChequeNoRequired, "Enter the cheque number for a cheque payment.", 400);
return request.Lines.Sum(l => l.Amount);
}
private async Task<IReadOnlyDictionary<long, GlBankAccount>> ResolveAccountsAsync(CreateExpensePaymentRequest request, CancellationToken ct)
{
var all = await _gl.ListBankAccountsAsync(ct);
var result = new Dictionary<long, GlBankAccount>();
foreach (var id in request.Lines.Select(l => l.GlBankAccountId).Distinct())
{
result[id] = all.FirstOrDefault(a => a.AccountId == id)
?? throw new DomainException(ErrorCodes.ExpenseBankAccountNotFound, $"Bank/cash account {id} was not found.", 404);
}
return result;
}
/// <summary>Same rule as <see cref="PurchaseInvoicePaymentService"/>: omitted ⇒ today, one day of
/// slack on "future" for clients east of UTC; a back-dated payment is stamped at that day's start.</summary>
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
{
var today = DateOnly.FromDateTime(now);
var day = requested ?? today;
if (day > today.AddDays(1))
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
}
}
+390
View File
@@ -0,0 +1,390 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Business expense bills (docs/EXPENSE_MODULE_PLAN.md §4). Draft is editable and has no GL
/// impact; Post accrues it (Debit the title's expense account / Credit Accrued Expenses) via a
/// real GL journal entry posted before commit — same atomicity approach as
/// <see cref="PurchaseInvoiceService.PostAsync"/>, so a GL rejection rolls the post back.
/// Payments settle the accrual (<see cref="ExpensePaymentService"/>). Cancelling a Posted
/// expense with no payments posts the reversing entry.
/// </summary>
public sealed class ExpenseService : IExpenseService
{
private readonly IRepository<Expense> _expenses;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IRepository<Vendor> _vendors;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glAccruedExpensesCode;
public ExpenseService(
IRepository<Expense> expenses, IRepository<ExpenseTitle> titles, IRepository<Vendor> vendors,
ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_expenses = expenses;
_titles = titles;
_vendors = vendors;
_currentUser = currentUser;
_numbers = numbers;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
}
public async Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default)
{
IQueryable<Expense> q = _expenses.Query().AsNoTracking().Include(x => x.Category).Include(x => x.Title);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|| (x.Payee != null && EF.Functions.Like(x.Payee, $"%{term}%"))
|| (x.BillReference != null && EF.Functions.Like(x.BillReference, $"%{term}%")));
}
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
if (filter.PaymentStatus is not null) q = q.Where(x => x.Status == ExpenseStatus.Posted && x.PaymentStatus == filter.PaymentStatus);
if (filter.CategoryId is not null) q = q.Where(x => x.ExpenseCategoryId == filter.CategoryId);
if (filter.TitleId is not null) q = q.Where(x => x.ExpenseTitleId == filter.TitleId);
if (filter.From is not null) q = q.Where(x => x.ExpenseDate >= filter.From);
if (filter.To is not null) q = q.Where(x => x.ExpenseDate <= filter.To);
if (filter.OverdueOnly)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today);
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.ExpenseDate).ThenByDescending(x => x.ExpenseId)
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<ExpenseSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<ExpenseDto>?> GetAsync(int expenseId, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: false, ct);
return expense is null ? null : new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
}
public async Task<ETagged<ExpenseDto>> CreateAsync(CreateExpenseRequest request, CancellationToken ct = default)
{
var title = await ValidateTitleAsync(request, ct);
var vendorName = await ValidateVendorAsync(request.VendorId, ct);
ValidateDates(request);
var branchId = request.BranchId > 0
? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct)
: await _branchConfig.ResolveDefaultBranchIdAsync(ct);
var expense = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new Expense
{
DocNo = await _numbers.NextAsync(DocumentTypes.Expense, branchId, token),
BranchId = branchId,
Status = ExpenseStatus.Draft,
PaymentStatus = ExpensePaymentStatus.Unpaid,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow
};
Apply(entity, request, vendorName);
await _expenses.AddAsync(entity, token);
return entity;
}, ct);
expense.Category = title.Category;
expense.Title = title;
return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
}
public async Task<ETagged<ExpenseDto>> UpdateAsync(int expenseId, UpdateExpenseRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
EnsureDraft(expense, "edited");
var title = await ValidateTitleAsync(request, ct);
var vendorName = await ValidateVendorAsync(request.VendorId, ct);
ValidateDates(request);
if (request.BranchId > 0 && request.BranchId != expense.BranchId)
expense.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
Apply(expense, request, vendorName);
expense.Category = title.Category;
expense.Title = title;
expense.UpdatedAt = DateTime.UtcNow;
await SaveAsync(ct);
return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
}
public async Task DeleteAsync(int expenseId, CancellationToken ct = default)
{
var expense = await _expenses.Query().FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
EnsureDraft(expense, "deleted");
_expenses.Remove(expense);
await _uow.SaveChangesAsync(ct);
}
public async Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
await _uow.ExecuteInTransactionAsync(token => PostInCurrentTransactionAsync(expense, token), ct);
return Map(expense);
}
/// <summary>
/// Posts the accrual entry and flips the expense to Posted. Must run inside the caller's
/// transaction (the caller commits) — shared with <see cref="ExpensePaymentService.PostAndPayAsync"/>
/// so Post &amp; Pay is one atomic unit.
/// </summary>
internal async Task<int> PostInCurrentTransactionAsync(Expense expense, CancellationToken ct)
{
EnsureDraft(expense, "posted");
var title = expense.Title ?? throw new InvalidOperationException("Expense title must be loaded before posting.");
if (title.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.ExpenseTitleInactive, $"Expense title '{title.Name}' is inactive — pick another title before posting.", 409);
var now = DateTime.UtcNow;
var narration = $"{expense.Category?.Name} - {title.Name} ({expense.DocNo})";
var period = await _gl.GetPeriodByDateAsync(expense.ExpenseDate, ct);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = expense.ExpenseDate,
SourceModule = "EXPENSE",
Reference = expense.DocNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(title.GlExpenseAccountCode, expense.Amount, 0m, narration),
new GlJournalEntryLineRequest(_glAccruedExpensesCode, 0m, expense.Amount, narration)
]
}, ct);
expense.Status = ExpenseStatus.Posted;
expense.GlExpenseAccountCode = title.GlExpenseAccountCode;
expense.GlJournalNo = posted.JournalNo;
expense.GlPostedAt = now;
expense.UpdatedAt = now;
return 0;
}
public async Task<ExpenseDto> CancelAsync(int expenseId, CancelExpenseRequest request, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.Status == ExpenseStatus.Cancelled)
throw new ConflictException($"Expense {expense.DocNo} is already cancelled.");
if (expense.PaidAmount > 0)
throw new DomainException(ErrorCodes.ExpenseHasPayments, $"Expense {expense.DocNo} has payments recorded and cannot be cancelled.", 409);
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
// A Draft never reached GL, so there is nothing to reverse.
if (expense.Status == ExpenseStatus.Posted)
{
var today = DateOnly.FromDateTime(now);
var narration = $"Cancellation of expense {expense.DocNo}";
var period = await _gl.GetPeriodByDateAsync(today, token);
var reversal = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = today,
SourceModule = "EXPENSE",
Reference = expense.DocNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(_glAccruedExpensesCode, expense.Amount, 0m, narration),
new GlJournalEntryLineRequest(expense.GlExpenseAccountCode ?? string.Empty, 0m, expense.Amount, narration)
]
}, token);
expense.GlReversalJournalNo = reversal.JournalNo;
}
expense.Status = ExpenseStatus.Cancelled;
expense.CancelReason = request.Reason.Trim();
expense.UpdatedAt = now;
return 0;
}, ct);
return Map(expense);
}
public async Task<ExpenseSummaryReportDto> GetSummaryAsync(DateOnly? from, DateOnly? to, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var start = from ?? new DateOnly(today.Year, today.Month, 1);
var end = to ?? start.AddMonths(1).AddDays(-1);
if (end < start)
throw new DomainException(ErrorCodes.Validation, "'to' must not be before 'from'.", 400);
var posted = _expenses.Query().AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted);
var inRange = await posted
.Where(x => x.ExpenseDate >= start && x.ExpenseDate <= end)
.Select(x => new
{
x.ExpenseCategoryId, CategoryName = x.Category!.Name, CategorySort = x.Category.SortOrder,
x.ExpenseTitleId, TitleName = x.Title!.Name, x.Amount, x.PaidAmount, x.BalanceAmount
})
.ToListAsync(ct);
var outstanding = await posted.Where(x => x.BalanceAmount > 0)
.Select(x => new { x.BalanceAmount, x.DueDate })
.ToListAsync(ct);
var overdue = outstanding.Where(x => x.DueDate != null && x.DueDate < today).ToList();
var draftCount = await _expenses.Query().CountAsync(x => x.Status == ExpenseStatus.Draft, ct);
var categories = inRange
.GroupBy(x => new { x.ExpenseCategoryId, x.CategoryName, x.CategorySort })
.OrderBy(g => g.Key.CategorySort).ThenBy(g => g.Key.CategoryName)
.Select(g => new ExpenseCategoryTotalDto(
g.Key.ExpenseCategoryId, g.Key.CategoryName, g.Sum(x => x.Amount), g.Count(),
g.GroupBy(x => new { x.ExpenseTitleId, x.TitleName })
.OrderByDescending(t => t.Sum(x => x.Amount))
.Select(t => new ExpenseTitleTotalDto(t.Key.ExpenseTitleId, t.Key.TitleName, t.Sum(x => x.Amount), t.Count()))
.ToList()))
.ToList();
return new ExpenseSummaryReportDto(
start, end,
inRange.Sum(x => x.Amount), inRange.Sum(x => x.PaidAmount), inRange.Sum(x => x.BalanceAmount), inRange.Count,
outstanding.Sum(x => x.BalanceAmount), overdue.Sum(x => x.BalanceAmount), overdue.Count, draftCount,
categories);
}
internal Task<Expense?> LoadAsync(int expenseId, bool tracking, CancellationToken ct)
{
var q = _expenses.Query()
.Include(x => x.Category)
.Include(x => x.Title)
.Include(x => x.Vendor)
.Include(x => x.Payments);
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct);
}
private async Task<ExpenseTitle> ValidateTitleAsync(ExpenseRequestBase request, CancellationToken ct)
{
// Tracked on purpose: UpdateAsync assigns this to the tracked expense's navigations, and
// identity resolution hands back the instance the expense already loaded.
var title = await _titles.Query().Include(t => t.Category)
.FirstOrDefaultAsync(t => t.ExpenseTitleId == request.ExpenseTitleId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Expense title {request.ExpenseTitleId} does not exist.", 422);
if (title.ExpenseCategoryId != request.ExpenseCategoryId)
throw new DomainException(ErrorCodes.ExpenseTitleCategoryMismatch, $"Title '{title.Name}' does not belong to the selected category.", 422);
if (title.Status != EntityStatus.Active || title.Category?.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.ExpenseTitleInactive, $"Expense title '{title.Name}' or its category is inactive.", 422);
return title;
}
private async Task<string?> ValidateVendorAsync(int? vendorId, CancellationToken ct)
{
if (vendorId is null) return null;
return await _vendors.Query().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => v.Name).FirstOrDefaultAsync(ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
}
private static void ValidateDates(ExpenseRequestBase request)
{
// One day of slack, as with payment dates: the client sends its local date.
if (request.ExpenseDate > DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1))
throw new DomainException(ErrorCodes.Validation, "Expense date cannot be in the future.", 400);
if (request.DueDate is not null && request.DueDate < request.ExpenseDate)
throw new DomainException(ErrorCodes.Validation, "Due date cannot be before the expense date.", 400);
}
private static void Apply(Expense expense, ExpenseRequestBase request, string? vendorName)
{
expense.ExpenseDate = request.ExpenseDate;
expense.DueDate = request.DueDate;
expense.ExpenseCategoryId = request.ExpenseCategoryId;
expense.ExpenseTitleId = request.ExpenseTitleId;
expense.VendorId = request.VendorId;
// A linked vendor names the payee unless one was typed explicitly.
expense.Payee = Normalize(request.Payee) ?? vendorName;
expense.BillReference = Normalize(request.BillReference);
expense.Description = Normalize(request.Description);
expense.PeriodMonth = Normalize(request.PeriodMonth);
expense.Amount = request.Amount;
expense.PaidAmount = 0m;
expense.BalanceAmount = request.Amount;
expense.PaymentStatus = ExpensePaymentStatus.Unpaid;
}
private static void EnsureDraft(Expense expense, string verb)
{
if (expense.Status != ExpenseStatus.Draft)
throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status} and cannot be {verb}.", 409);
}
private async Task SaveAsync(CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
}
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
internal static ExpensePaymentStatus DerivePaymentStatus(decimal paidAmount, decimal balanceAmount) =>
balanceAmount <= 0 ? ExpensePaymentStatus.Paid
: paidAmount > 0 ? ExpensePaymentStatus.PartiallyPaid
: ExpensePaymentStatus.Unpaid;
private static ExpenseSummaryDto MapSummary(Expense x) => new(
x.ExpenseId, x.DocNo, x.BranchId, x.ExpenseDate, x.DueDate,
x.ExpenseCategoryId, x.Category?.Name ?? string.Empty, x.ExpenseTitleId, x.Title?.Name ?? string.Empty,
x.Payee, x.BillReference, x.PeriodMonth, x.Amount, x.PaidAmount, x.BalanceAmount,
x.Status, x.PaymentStatus, x.GlJournalNo, x.CreatedAt);
internal static ExpenseDto Map(Expense x) => new(
x.ExpenseId, x.DocNo, x.BranchId, x.ExpenseDate, x.DueDate,
x.ExpenseCategoryId, x.Category?.Name ?? string.Empty, x.ExpenseTitleId, x.Title?.Name ?? string.Empty,
x.Payee, x.VendorId, x.Vendor?.Name, x.BillReference, x.Description, x.PeriodMonth,
x.Amount, x.PaidAmount, x.BalanceAmount, x.Status, x.PaymentStatus,
x.GlExpenseAccountCode, x.GlJournalNo, x.GlPostedAt, x.GlReversalJournalNo, x.CancelReason,
x.CreatedBy, x.CreatedAt, x.UpdatedAt,
x.Payments.OrderByDescending(p => p.ExpensePaymentId).Select(MapPayment).ToList());
internal static ExpensePaymentDto MapPayment(ExpensePayment p) => new(
p.ExpensePaymentId, p.ExpenseId, p.Amount, p.PaymentDate, p.PaymentMethod,
p.GlBankAccountId, p.BankAccountName, p.ChequeNo, p.Reference, p.GlJournalNo, p.CreatedAt);
}
@@ -0,0 +1,133 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Expense categories and titles. Categories are the fixed seeded four (no create — only their
/// active flag changes). Seeded rows (<c>IsSystem</c>) keep their names — they can be
/// deactivated, and a title's GL account code can be corrected — while user-added titles
/// are fully editable. Nothing is hard-deleted: expenses reference titles, so retiring one
/// means setting it Inactive (it stops being offered on new expenses).
/// </summary>
public sealed class ExpenseSetupService : IExpenseSetupService
{
private readonly IRepository<ExpenseCategory> _categories;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IUnitOfWork _uow;
public ExpenseSetupService(IRepository<ExpenseCategory> categories, IRepository<ExpenseTitle> titles, IUnitOfWork uow)
{
_categories = categories;
_titles = titles;
_uow = uow;
}
public async Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default)
{
var categories = await _categories.Query().AsNoTracking()
.Include(c => c.Titles)
.Where(c => includeInactive || c.Status == EntityStatus.Active)
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
.ToListAsync(ct);
return categories.Select(c => Map(c, includeInactive)).ToList();
}
public async Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
{
var category = await _categories.Query().Include(c => c.Titles)
.FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct)
?? throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
var name = request.Name.Trim();
if (name != category.Name)
{
if (category.IsSystem)
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409);
if (await _categories.Query().AnyAsync(c => c.Name == name && c.ExpenseCategoryId != expenseCategoryId, ct))
throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409);
category.Name = name;
}
category.Status = request.Status;
category.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(category, includeInactive: true);
}
public async Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default)
{
if (!await _categories.Query().AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct))
throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
var name = request.Name.Trim();
await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, ct);
var nextSort = (await _titles.Query().Where(t => t.ExpenseCategoryId == expenseCategoryId)
.MaxAsync(t => (int?)t.SortOrder, ct) ?? 0) + 1;
var title = new ExpenseTitle
{
ExpenseCategoryId = expenseCategoryId,
Name = name,
GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(),
IsSystem = false,
SortOrder = nextSort,
CreatedAt = DateTime.UtcNow
};
await _titles.AddAsync(title, ct);
await _uow.SaveChangesAsync(ct);
return Map(title);
}
public async Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default)
{
var title = await _titles.Query().FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct)
?? throw new NotFoundException($"Expense title {expenseTitleId} was not found.");
var name = request.Name.Trim();
if (name != title.Name)
{
if (title.IsSystem)
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409);
await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, ct);
title.Name = name;
}
// The GL code is editable on system rows too — the seeded codes are placeholders.
// Already-posted expenses keep the code they snapshotted at post time.
title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim();
title.Status = request.Status;
title.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(title);
}
private async Task EnsureTitleNameIsUniqueAsync(int expenseCategoryId, string name, int? excludingTitleId, CancellationToken ct)
{
var duplicate = await _titles.Query().AnyAsync(t =>
t.ExpenseCategoryId == expenseCategoryId && t.Name == name
&& (excludingTitleId == null || t.ExpenseTitleId != excludingTitleId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateExpenseTitle, $"A title named '{name}' already exists in this category.", 409);
}
private static ExpenseCategoryDto Map(ExpenseCategory c, bool includeInactive) => new(
c.ExpenseCategoryId, c.Code, c.Name, c.IsSystem, c.Status, c.SortOrder,
c.Titles
.Where(t => includeInactive || t.Status == EntityStatus.Active)
.OrderBy(t => t.SortOrder).ThenBy(t => t.Name)
.Select(Map).ToList());
private static ExpenseTitleDto Map(ExpenseTitle t) => new(
t.ExpenseTitleId, t.ExpenseCategoryId, t.Name, t.GlExpenseAccountCode, t.IsSystem, t.Status, t.SortOrder);
}
@@ -1,15 +1,25 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock; using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces; namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary> /// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5; docs/DUAL_APPROVAL_PLAN.md §5).</summary>
public interface IAdjustmentService public interface IAdjustmentService
{ {
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync( Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default); PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default); Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
/// <summary>Submits an adjustment request — it posts at once only when approvals are switched off.</summary>
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default); Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
/// <summary>Gives the next approval; the last one posts the stock movement and GL.</summary>
Task<AdjustmentDto> ApproveAsync(int adjustmentId, string? comment, CancellationToken ct = default);
Task<AdjustmentDto> RejectAsync(int adjustmentId, string reason, CancellationToken ct = default);
/// <summary>The requester withdraws a request that is still pending.</summary>
Task<AdjustmentDto> CancelAsync(int adjustmentId, CancellationToken ct = default);
} }
@@ -0,0 +1,48 @@
using ERPCore.Dtos.Approvals;
namespace ERPCore.Services.Interfaces;
/// <summary>Where a document stands in its approval, as the shared engine needs to see it.</summary>
/// <param name="NavCode">The screen whose Approve action an approver needs (e.g. <c>stock.adjustments</c>).</param>
/// <param name="CurrentLevel">The approval being given now: 1, or 2 after the first.</param>
public sealed record ApprovalTarget(
string DocType, int DocId, string DocNo, string NavCode, int RequestedBy, int RequiredApprovals, int CurrentLevel, int? Level1ApprovedBy);
public enum ApprovalOutcome
{
AdvancedToLevel2,
FullyApproved,
Rejected
}
/// <summary>
/// The shared maker–checker rules for stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §4):
/// the requester can't approve, the two approvers must differ, level 2 may be limited to roles.
/// The Approve permission itself is checked by the endpoint's policy. Decisions are added to the
/// caller's unit of work — the document service saves them with its own changes.
/// </summary>
public interface IApprovalService
{
Task<ApprovalConfigDto> GetConfigAsync(CancellationToken ct = default);
Task<ApprovalConfigDto> UpdateConfigAsync(UpdateApprovalConfigRequest request, CancellationToken ct = default);
/// <summary>Approvals a stock adjustment worth <paramref name="absoluteValue"/> needs (0, 1 or 2).</summary>
Task<int> RequiredForStockAdjustmentAsync(decimal absoluteValue, CancellationToken ct = default);
/// <summary>Approvals a price change of <paramref name="largestChangePercent"/> needs (0, 1 or 2).</summary>
Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default);
/// <summary>Validates the current user may decide, and records the decision (unsaved).</summary>
Task<ApprovalOutcome> DecideAsync(ApprovalTarget target, bool approve, string? comment, CancellationToken ct = default);
/// <summary>Null when the current user may decide; otherwise the reason they can't.</summary>
Task<string?> WhyCannotDecideAsync(ApprovalTarget target, CancellationToken ct = default);
Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct = default);
/// <summary>Active users who could give <paramref name="target"/>'s current approval — for notifications.</summary>
Task<IReadOnlyList<int>> ApproverIdsAsync(ApprovalTarget target, CancellationToken ct = default);
/// <summary>Notifies whoever can give the target's next approval that it is waiting.</summary>
Task NotifyPendingAsync(ApprovalTarget target, string summary, CancellationToken ct = default);
}
@@ -0,0 +1,40 @@
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Till cash in hand per branch and business day (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.1) — the one
/// calculation both the deposit form and the Day-End Sales Report read. A branch-scoped caller
/// always gets their own branch; <c>branchId</c> null means every branch together.
/// </summary>
public interface ICashInHandService
{
/// <summary>The configured <c>BankDeposit:CashTrackingStartDate</c>, if any.</summary>
DateOnly? TrackingStartDate { get; }
/// <summary><paramref name="day"/> null ⇒ today in the business time zone.</summary>
Task<CashInHandDto> GetPositionAsync(DateOnly? day, int? branchId, CancellationToken ct = default);
/// <summary>Every day of <c>from</c>..<c>to</c> (at most 92 days), opening carried in from earlier days.</summary>
Task<CashInHandDailyDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
/// <summary>Same as <see cref="GetDailyAsync"/> without the range cap — for callers that validate their own range.</summary>
Task<IReadOnlyList<CashPositionDto>> GetRangeAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
/// <summary>
/// The most that can still be deposited against <paramref name="day"/>: the lowest closing balance
/// from that day to today, never below zero; zero for a future day or one before tracking starts.
/// <paramref name="branchScope"/> is used as given (the caller has already resolved it).
/// </summary>
Task<decimal> GetAvailableToDepositAsync(DateOnly day, int? branchScope, CancellationToken ct = default);
}
/// <summary>Bank deposits of till cash — record (posts GL), cancel (reverses GL), list (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2).</summary>
public interface IBankDepositService
{
Task<BankDepositListDto> ListAsync(PageQuery query, BankDepositListFilter filter, CancellationToken ct = default);
Task<BankDepositDto?> GetAsync(int bankDepositId, CancellationToken ct = default);
Task<BankDepositDto> CreateAsync(CreateBankDepositRequest request, CancellationToken ct = default);
Task<BankDepositDto> CancelAsync(int bankDepositId, CancelBankDepositRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using ERPCore.Dtos.Reports;
namespace ERPCore.Services.Interfaces;
/// <summary>Read-only day-end sales/expense/income report (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
public interface IDayEndReportService
{
/// <summary><paramref name="date"/> null ⇒ today in the report's time zone.</summary>
Task<DayEndReportDto> GetDayAsync(DateOnly? date, int? branchId, CancellationToken ct = default);
Task<DayEndDailyReportDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
}
@@ -0,0 +1,35 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Expenses;
namespace ERPCore.Services.Interfaces;
/// <summary>Expense categories and their titles (docs/EXPENSE_MODULE_PLAN.md §2.1).</summary>
public interface IExpenseSetupService
{
Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default);
Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default);
Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default);
Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default);
}
/// <summary>Expense bills: draft CRUD, post (GL accrual), cancel and reporting (docs/EXPENSE_MODULE_PLAN.md §4).</summary>
public interface IExpenseService
{
Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default);
Task<ETagged<ExpenseDto>?> GetAsync(int expenseId, CancellationToken ct = default);
Task<ETagged<ExpenseDto>> CreateAsync(CreateExpenseRequest request, CancellationToken ct = default);
Task<ETagged<ExpenseDto>> UpdateAsync(int expenseId, UpdateExpenseRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task DeleteAsync(int expenseId, CancellationToken ct = default);
Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default);
Task<ExpenseDto> CancelAsync(int expenseId, CancelExpenseRequest request, CancellationToken ct = default);
Task<ExpenseSummaryReportDto> GetSummaryAsync(DateOnly? from, DateOnly? to, CancellationToken ct = default);
}
/// <summary>Payments against a Posted expense's balance, and the one-step Post &amp; Pay.</summary>
public interface IExpensePaymentService
{
Task<IReadOnlyList<ExpensePaymentDto>> PayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default);
Task<ExpenseDto> PostAndPayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default);
}
@@ -34,5 +34,8 @@ public interface IItemService
/// <summary>Barcode/RFID scan lookup — item barcode/SKU, or a batch/serial barcode/no /// <summary>Barcode/RFID scan lookup — item barcode/SKU, or a batch/serial barcode/no
/// (optionally scoped to <paramref name="itemId"/> first). Null when nothing matches.</summary> /// (optionally scoped to <paramref name="itemId"/> first). Null when nothing matches.</summary>
/// <summary>The item's most recent posted GRN receipts (newest first) — what it was bought at.</summary>
Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default);
Task<ItemScanResultDto?> ScanAsync(string code, int? itemId, CancellationToken ct = default); Task<ItemScanResultDto?> ScanAsync(string code, int? itemId, CancellationToken ct = default);
} }
@@ -17,6 +17,19 @@ public interface INotificationService
int? relatedEntityId = null, int? relatedEntityId = null,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// Same as <see cref="RaiseAsync"/> but delivered only to <paramref name="userIds"/> (active ones) —
/// e.g. the approvers of a request, or its requester. Same InApp-channel gate; no-op for an empty list.
/// </summary>
Task NotifyUsersAsync(
IEnumerable<int> userIds,
string moduleKey,
string title,
string message,
string? relatedEntityType = null,
int? relatedEntityId = null,
CancellationToken ct = default);
Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default); Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default);
Task MarkAllReadAsync(int userId, CancellationToken ct = default); Task MarkAllReadAsync(int userId, CancellationToken ct = default);
@@ -0,0 +1,25 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Products;
namespace ERPCore.Services.Interfaces;
/// <summary>Selling-price change requests with dual approval (docs/DUAL_APPROVAL_PLAN.md §6).</summary>
public interface IPriceAdjustmentService
{
Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default);
Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default);
/// <summary>Submits a request — applied at once only when price approvals are switched off.</summary>
Task<PriceAdjustmentDto> CreateAsync(CreatePriceAdjustmentRequest request, CancellationToken ct = default);
/// <summary>Gives the next approval; the last one writes the new prices to the items.</summary>
Task<PriceAdjustmentDto> ApproveAsync(int priceAdjustmentId, string? comment, CancellationToken ct = default);
Task<PriceAdjustmentDto> RejectAsync(int priceAdjustmentId, string reason, CancellationToken ct = default);
Task<PriceAdjustmentDto> CancelAsync(int priceAdjustmentId, CancellationToken ct = default);
Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default);
/// <summary>True when selling prices may only change through an approved price adjustment.</summary>
Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default);
}
+33 -1
View File
@@ -32,6 +32,12 @@ public sealed class ItemService : IItemService
private readonly IRepository<Brand> _brands; private readonly IRepository<Brand> _brands;
private readonly IRepository<Uom> _uoms; private readonly IRepository<Uom> _uoms;
private readonly IRepository<TaxRate> _taxRates; private readonly IRepository<TaxRate> _taxRates;
private readonly IRepository<ApprovalConfig> _approvalConfig;
/// <summary>Price approvals on (the default) unless Settings → Approvals set price levels to 0.</summary>
private async Task<bool> PricesNeedApprovalAsync(CancellationToken ct)
=> (await _approvalConfig.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
private readonly IRepository<Vendor> _vendors; private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers; private readonly IRepository<StockLayer> _stockLayers;
@@ -59,8 +65,10 @@ public sealed class ItemService : IItemService
IRepository<Serial> serials, IRepository<Serial> serials,
IProductConfigService config, IProductConfigService config,
INotificationService notifications, INotificationService notifications,
IUnitOfWork uow) IUnitOfWork uow,
IRepository<ApprovalConfig> approvalConfig)
{ {
_approvalConfig = approvalConfig;
_items = items; _items = items;
_categories = categories; _categories = categories;
_subCategories = subCategories; _subCategories = subCategories;
@@ -207,6 +215,21 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
} }
public async Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
throw new NotFoundException($"Item {itemId} was not found.");
return await _grnLines.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null)
.OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId)
.Take(Math.Clamp(take, 1, 50))
.Select(l => new ItemPurchasePriceDto(
l.GrnId, l.Grn!.DocNo, l.Grn.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name, l.Grn.Warehouse!.Code,
l.Qty, l.UnitCost, l.DiscountPct, l.NetUnitCost, l.VatPct))
.ToListAsync(ct);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default) public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{ {
var item = await _items.Query().AsNoTracking() var item = await _items.Query().AsNoTracking()
@@ -309,6 +332,15 @@ public sealed class ItemService : IItemService
ErrorCodes.MasterInUse, ErrorCodes.MasterInUse,
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409); $"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
// With price approvals on, selling prices change only through an approved price adjustment
// (docs/DUAL_APPROVAL_PLAN.md §6.3). Sending the current prices back unchanged is fine.
var pricesChanged = item.SellingMinPrice != request.SellingMinPrice
|| item.SellingMaxPrice != request.SellingMaxPrice
|| item.SellingFlatPrice != request.SellingFlatPrice;
if (pricesChanged && await PricesNeedApprovalAsync(ct))
throw new DomainException(ErrorCodes.PriceChangeRequiresApproval,
"Selling prices need approval — raise a price adjustment (Products → Price Adjustments) instead of editing them here.", 409);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit); ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit); var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod( var warrantyPeriodMonths = ValidateWarrantyPeriod(
@@ -39,16 +39,43 @@ public sealed class NotificationService : INotificationService
int? relatedEntityId = null, int? relatedEntityId = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var channels = await _config.ResolveEnabledChannelsAsync(moduleKey, ct);
if (!channels.Contains(NotificationChannel.InApp)) return;
var activeUserIds = await _users.Query().AsNoTracking() var activeUserIds = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active) .Where(u => u.Status == EntityStatus.Active)
.Select(u => u.UserId) .Select(u => u.UserId)
.ToListAsync(ct); .ToListAsync(ct);
await DeliverAsync(activeUserIds, moduleKey, title, message, relatedEntityType, relatedEntityId, ct);
}
public async Task NotifyUsersAsync(
IEnumerable<int> userIds,
string moduleKey,
string title,
string message,
string? relatedEntityType = null,
int? relatedEntityId = null,
CancellationToken ct = default)
{
var wanted = userIds.Distinct().ToList();
if (wanted.Count == 0) return;
var activeUserIds = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active && wanted.Contains(u.UserId))
.Select(u => u.UserId)
.ToListAsync(ct);
await DeliverAsync(activeUserIds, moduleKey, title, message, relatedEntityType, relatedEntityId, ct);
}
private async Task DeliverAsync(
List<int> activeUserIds, string moduleKey, string title, string message,
string? relatedEntityType, int? relatedEntityId, CancellationToken ct)
{
if (activeUserIds.Count == 0) return; if (activeUserIds.Count == 0) return;
var channels = await _config.ResolveEnabledChannelsAsync(moduleKey, ct);
if (!channels.Contains(NotificationChannel.InApp)) return;
var notification = new Notification var notification = new Notification
{ {
ModuleKey = moduleKey, ModuleKey = moduleKey,
@@ -0,0 +1,364 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Products;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6). A request snapshots each item's
/// current prices and the prices wanted; nothing changes until it is fully approved (rules in
/// <see cref="IApprovalService"/>). Approval then writes the new prices to the items and records an
/// <see cref="ItemPriceHistory"/> row — unless an item was repriced since the request was made, in
/// which case the approval is refused as stale. One pending request per item at a time.
/// </summary>
public sealed class PriceAdjustmentService : IPriceAdjustmentService
{
public const string ApprovalDocType = "PADJ";
public const string ApprovalNavCode = "products.price-adjustments";
private readonly IRepository<PriceAdjustment> _adjustments;
private readonly IRepository<Item> _items;
private readonly IRepository<ItemPriceHistory> _history;
private readonly IRepository<User> _users;
private readonly IRepository<ApprovalConfig> _config;
private readonly IApprovalService _approvals;
private readonly INotificationService _notifications;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public PriceAdjustmentService(
IRepository<PriceAdjustment> adjustments, IRepository<Item> items, IRepository<ItemPriceHistory> history,
IRepository<User> users, IRepository<ApprovalConfig> config, IApprovalService approvals,
INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_items = items;
_history = history;
_users = users;
_config = config;
_approvals = approvals;
_notifications = notifications;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%"));
}
if (status is not null) q = q.Where(p => p.Status == status);
if (itemId is not null) q = q.Where(p => p.Lines.Any(l => l.ItemId == itemId));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(p => p.PriceAdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(p => new PriceAdjustmentSummaryDto(
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals,
p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : p.Status == PriceAdjustmentStatus.PendingSecondApproval ? 2 : null,
p.Lines.Count, p.CreatedBy, p.Creator!.DisplayName, p.CreatedAt, p.AppliedAt))
.ToListAsync(ct);
return PagedResponse<PriceAdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default)
{
var adjustment = await Load(tracking: false).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct);
return adjustment is null ? null : await ToDtoAsync(adjustment, ct);
}
public async Task<PriceAdjustmentDto> CreateAsync(CreatePriceAdjustmentRequest request, CancellationToken ct = default)
{
var reason = request.Reason?.Trim();
if (string.IsNullOrEmpty(reason))
throw new DomainException(ErrorCodes.Validation, "Give a reason for the price change.", 400);
if (request.Lines.Count == 0)
throw new DomainException(ErrorCodes.Validation, "Add at least one item.", 400);
if (request.Lines.Select(l => l.ItemId).Distinct().Count() != request.Lines.Count)
throw new DomainException(ErrorCodes.Validation, "Each item can appear only once in a request.", 400);
var ids = request.Lines.Select(l => l.ItemId).ToList();
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
var pendingItemIds = await _adjustments.Query().AsNoTracking()
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
.SelectMany(p => p.Lines.Select(l => new { l.ItemId, p.DocNo }))
.Where(x => ids.Contains(x.ItemId))
.ToListAsync(ct);
var lines = new List<PriceAdjustmentLine>();
var largestChange = 0m;
foreach (var input in request.Lines)
{
if (!items.TryGetValue(input.ItemId, out var item))
throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
var clash = pendingItemIds.FirstOrDefault(x => x.ItemId == input.ItemId);
if (clash is not null)
throw new DomainException(ErrorCodes.PriceAdjustmentItemPending,
$"{item.Sku} already has a pending price change ({clash.DocNo}) — approve, reject or withdraw that first.", 409);
ValidatePrices(item.Sku, input.NewMinPrice, input.NewMaxPrice, input.NewFlatPrice);
if (input.NewMinPrice == item.SellingMinPrice && input.NewMaxPrice == item.SellingMaxPrice && input.NewFlatPrice == item.SellingFlatPrice)
throw new DomainException(ErrorCodes.Validation, $"{item.Sku}: the new prices are the same as the current ones.", 400);
largestChange = Math.Max(largestChange, Math.Max(
ChangePercent(item.SellingMinPrice, input.NewMinPrice),
Math.Max(ChangePercent(item.SellingMaxPrice, input.NewMaxPrice), ChangePercent(item.SellingFlatPrice, input.NewFlatPrice))));
lines.Add(new PriceAdjustmentLine
{
ItemId = item.ItemId,
OldMinPrice = item.SellingMinPrice, OldMaxPrice = item.SellingMaxPrice, OldFlatPrice = item.SellingFlatPrice,
NewMinPrice = input.NewMinPrice, NewMaxPrice = input.NewMaxPrice, NewFlatPrice = input.NewFlatPrice
});
}
var required = await _approvals.RequiredForPriceAdjustmentAsync(largestChange, ct);
var now = DateTime.UtcNow;
var adjustment = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new PriceAdjustment
{
DocNo = await _numbers.NextAsync(DocumentTypes.PriceAdjustment, token),
Reason = reason,
Status = required == 0 ? PriceAdjustmentStatus.Approved : PriceAdjustmentStatus.PendingApproval,
RequiredApprovals = required,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = lines
};
await _adjustments.AddAsync(entity, token);
if (required == 0)
await ApplyInCurrentTransactionAsync(entity, items, now, token);
return entity;
}, ct);
if (required > 0)
await _approvals.NotifyPendingAsync(Target(adjustment), Summary(adjustment), ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<PriceAdjustmentDto> ApproveAsync(int priceAdjustmentId, string? comment, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
var target = Target(adjustment);
var outcome = await _approvals.DecideAsync(target, approve: true, comment, ct);
var now = DateTime.UtcNow;
if (target.CurrentLevel == 1)
{
adjustment.ApprovedLevel1By = _currentUser.AuditUserId;
adjustment.ApprovedLevel1At = now;
}
else
{
adjustment.ApprovedLevel2By = _currentUser.AuditUserId;
adjustment.ApprovedLevel2At = now;
}
try
{
await _uow.ExecuteInTransactionAsync(async token =>
{
if (outcome == ApprovalOutcome.AdvancedToLevel2)
{
adjustment.Status = PriceAdjustmentStatus.PendingSecondApproval;
return 0;
}
var ids = adjustment.Lines.Select(l => l.ItemId).ToList();
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, token);
adjustment.Status = PriceAdjustmentStatus.Approved;
await ApplyInCurrentTransactionAsync(adjustment, items, now, token);
return 0;
}, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
if (outcome == ApprovalOutcome.AdvancedToLevel2)
await _approvals.NotifyPendingAsync(Target(adjustment), Summary(adjustment), ct);
else
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.approved",
$"Approved: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was approved — the new prices are now in effect.",
ApprovalDocType, adjustment.PriceAdjustmentId, ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<PriceAdjustmentDto> RejectAsync(int priceAdjustmentId, string reason, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
adjustment.Status = PriceAdjustmentStatus.Rejected;
adjustment.RejectedBy = _currentUser.AuditUserId;
adjustment.RejectedAt = DateTime.UtcNow;
adjustment.RejectReason = reason.Trim();
await SaveDecisionAsync(adjustment, ct);
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
$"Rejected: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}",
ApprovalDocType, adjustment.PriceAdjustmentId, ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<PriceAdjustmentDto> CancelAsync(int priceAdjustmentId, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
if (adjustment.CreatedBy != _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
adjustment.Status = PriceAdjustmentStatus.Cancelled;
adjustment.CancelledAt = DateTime.UtcNow;
await SaveDecisionAsync(adjustment, ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default)
{
var rows = await _history.Query().AsNoTracking()
.Where(h => h.ItemId == itemId)
.OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId)
.ToListAsync(ct);
var userIds = rows.SelectMany(r => new int?[] { r.RequestedBy, r.ApprovedLevel1By, r.ApprovedLevel2By }).OfType<int>().Distinct().ToList();
var names = await _users.Query().AsNoTracking().Where(u => userIds.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct);
var docIds = rows.Select(r => r.PriceAdjustmentId).OfType<int>().Distinct().ToList();
var docNos = await _adjustments.Query().AsNoTracking().Where(p => docIds.Contains(p.PriceAdjustmentId)).ToDictionaryAsync(p => p.PriceAdjustmentId, p => p.DocNo, ct);
string? Name(int? id) => id is int v ? names.GetValueOrDefault(v) : null;
return rows.Select(h => new ItemPriceHistoryDto(
h.ItemPriceHistoryId, h.ItemId, h.PriceAdjustmentId, h.PriceAdjustmentId is int d ? docNos.GetValueOrDefault(d) : null,
h.OldMinPrice, h.OldMaxPrice, h.OldFlatPrice, h.NewMinPrice, h.NewMaxPrice, h.NewFlatPrice,
h.RequestedBy, Name(h.RequestedBy), h.ApprovedLevel1By, Name(h.ApprovedLevel1By), h.ApprovedLevel2By, Name(h.ApprovedLevel2By),
h.AppliedAt)).ToList();
}
public async Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default)
=> (await _config.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
// --- Helpers ---------------------------------------------------------------------------------
/// <summary>Writes each line's new prices to its item and records the history. Refuses the whole
/// request if any item's prices no longer match what the request was made against.</summary>
private async Task ApplyInCurrentTransactionAsync(PriceAdjustment adjustment, IReadOnlyDictionary<int, Item> items, DateTime now, CancellationToken ct)
{
foreach (var line in adjustment.Lines)
{
var item = items[line.ItemId];
if (item.SellingMinPrice != line.OldMinPrice || item.SellingMaxPrice != line.OldMaxPrice || item.SellingFlatPrice != line.OldFlatPrice)
throw new DomainException(ErrorCodes.PriceAdjustmentStale,
$"{item.Sku}'s prices changed after {adjustment.DocNo} was requested. Reject this request and raise a new one against the current prices.", 409);
item.SellingMinPrice = line.NewMinPrice;
item.SellingMaxPrice = line.NewMaxPrice;
item.SellingFlatPrice = line.NewFlatPrice;
item.UpdatedAt = now;
await _history.AddAsync(new ItemPriceHistory
{
ItemId = item.ItemId,
PriceAdjustment = adjustment, // FK set by EF, also when the request is new
OldMinPrice = line.OldMinPrice, OldMaxPrice = line.OldMaxPrice, OldFlatPrice = line.OldFlatPrice,
NewMinPrice = line.NewMinPrice, NewMaxPrice = line.NewMaxPrice, NewFlatPrice = line.NewFlatPrice,
RequestedBy = adjustment.CreatedBy,
ApprovedLevel1By = adjustment.ApprovedLevel1By,
ApprovedLevel2By = adjustment.ApprovedLevel2By,
AppliedAt = now
}, ct);
}
adjustment.AppliedAt = now;
}
private static void ValidatePrices(string sku, decimal? min, decimal? max, decimal? flat)
{
if (min < 0 || max < 0 || flat < 0)
throw new DomainException(ErrorCodes.Validation, $"{sku}: prices cannot be negative.", 400);
if (min is not null && max is not null && min > max)
throw new DomainException(ErrorCodes.Validation, $"{sku}: the minimum price is above the maximum.", 400);
if (flat is not null && ((min is not null && flat < min) || (max is not null && flat > max)))
throw new DomainException(ErrorCodes.Validation, $"{sku}: the flat price must be between the minimum and maximum.", 400);
}
/// <summary>Size of a change in percent; setting or clearing a price counts as 100%.</summary>
public static decimal ChangePercent(decimal? oldPrice, decimal? newPrice)
{
if (oldPrice == newPrice) return 0m;
if (oldPrice is null || newPrice is null || oldPrice == 0) return 100m;
return Math.Abs(newPrice.Value - oldPrice.Value) / oldPrice.Value * 100m;
}
private IQueryable<PriceAdjustment> Load(bool tracking)
{
var q = _adjustments.Query().Include(p => p.Lines).ThenInclude(l => l.Item).Include(p => p.Creator);
return tracking ? q : q.AsNoTracking();
}
private async Task<PriceAdjustment> LoadPendingAsync(int priceAdjustmentId, CancellationToken ct)
{
var adjustment = await _adjustments.Query().Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct)
?? throw new NotFoundException($"Price adjustment {priceAdjustmentId} was not found.");
if (adjustment.Status is not (PriceAdjustmentStatus.PendingApproval or PriceAdjustmentStatus.PendingSecondApproval))
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
return adjustment;
}
private async Task SaveDecisionAsync(PriceAdjustment adjustment, CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
}
private static int? PendingLevel(PriceAdjustment p) => p.Status switch
{
PriceAdjustmentStatus.PendingApproval => 1,
PriceAdjustmentStatus.PendingSecondApproval => 2,
_ => null
};
private static ApprovalTarget Target(PriceAdjustment p) => new(
ApprovalDocType, p.PriceAdjustmentId, p.DocNo, ApprovalNavCode, p.CreatedBy, p.RequiredApprovals, PendingLevel(p) ?? 1, p.ApprovedLevel1By);
private static string Summary(PriceAdjustment p)
=> $"Price adjustment {p.DocNo} ({p.Lines.Count} item{(p.Lines.Count == 1 ? "" : "s")}: {p.Reason})";
private async Task<PriceAdjustmentDto> ToDtoAsync(PriceAdjustment p, CancellationToken ct)
{
var pending = PendingLevel(p);
var whyNot = pending is null ? "Not waiting for approval." : await _approvals.WhyCannotDecideAsync(Target(p), ct);
return new PriceAdjustmentDto(
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, pending,
p.CreatedBy, p.Creator?.DisplayName, p.CreatedAt,
p.ApprovedLevel1By, p.ApprovedLevel1At, p.ApprovedLevel2By, p.ApprovedLevel2At,
p.RejectedBy, p.RejectedAt, p.RejectReason, p.CancelledAt, p.AppliedAt,
p.Lines.OrderBy(l => l.PriceAdjustmentLineId).Select(l => new PriceAdjustmentLineDto(
l.PriceAdjustmentLineId, l.ItemId, l.Item?.Sku ?? $"#{l.ItemId}", l.Item?.Name ?? string.Empty,
l.OldMinPrice, l.OldMaxPrice, l.OldFlatPrice, l.NewMinPrice, l.NewMaxPrice, l.NewFlatPrice,
l.Item?.SellingMinPrice, l.Item?.SellingMaxPrice, l.Item?.SellingFlatPrice)).ToList(),
await _approvals.HistoryAsync(ApprovalDocType, p.PriceAdjustmentId, ct),
whyNot is null, whyNot,
pending is not null && p.CreatedBy == _currentUser.AuditUserId);
}
}
@@ -91,6 +91,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
PaymentDate = paymentDate, PaymentDate = paymentDate,
GlBankAccountId = account.AccountId, GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName, BankAccountName = account.AccountName,
PaymentMethod = ResolveMethod(request.PaymentMethod, account),
Reference = request.Reference, Reference = request.Reference,
GlJournalNo = posted.JournalNo, GlJournalNo = posted.JournalNo,
CreatedBy = actor, CreatedBy = actor,
@@ -151,16 +152,17 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next. // Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList(); var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>(); var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, SalesPaymentMethod Method, decimal Amount)>();
var invIdx = 0; var invIdx = 0;
var invRemaining = ordered[0].BalanceAmount; var invRemaining = ordered[0].BalanceAmount;
foreach (var (line, account) in lines) foreach (var (line, account) in lines)
{ {
var lineRemaining = line.Amount; var lineRemaining = line.Amount;
var method = ResolveMethod(line.PaymentMethod, account);
while (lineRemaining > 0) while (lineRemaining > 0)
{ {
var take = Math.Min(lineRemaining, invRemaining); var take = Math.Min(lineRemaining, invRemaining);
allocations.Add((ordered[invIdx], account, take)); allocations.Add((ordered[invIdx], account, method, take));
lineRemaining -= take; lineRemaining -= take;
invRemaining -= take; invRemaining -= take;
if (invRemaining == 0 && invIdx < ordered.Count - 1) if (invRemaining == 0 && invIdx < ordered.Count - 1)
@@ -196,7 +198,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
}, token); }, token);
var entities = new List<SalesInvoicePayment>(); var entities = new List<SalesInvoicePayment>();
foreach (var (invoice, account, amount) in allocations) foreach (var (invoice, account, method, amount) in allocations)
{ {
var entity = new SalesInvoicePayment var entity = new SalesInvoicePayment
{ {
@@ -205,6 +207,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
PaymentDate = paymentDate, PaymentDate = paymentDate,
GlBankAccountId = account.AccountId, GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName, BankAccountName = account.AccountName,
PaymentMethod = method,
Reference = request.Reference, Reference = request.Reference,
GlJournalNo = posted.JournalNo, GlJournalNo = posted.JournalNo,
CreatedBy = actor, CreatedBy = actor,
@@ -238,6 +241,13 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
} }
/// <summary>The caller's method, or — when omitted (older clients) — the account's kind: money into a
/// Cash account is cash, money into a Bank account is taken to be a card payment.</summary>
private static SalesPaymentMethod ResolveMethod(SalesPaymentMethod? requested, GlBankAccount account)
=> requested ?? (string.Equals(account.AccountType, "Cash", StringComparison.OrdinalIgnoreCase)
? SalesPaymentMethod.Cash
: SalesPaymentMethod.Card);
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default) public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
{ {
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct)) if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
@@ -248,11 +258,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
.OrderByDescending(p => p.SalesInvoicePaymentId) .OrderByDescending(p => p.SalesInvoicePaymentId)
.Select(p => new SalesInvoicePaymentDto( .Select(p => new SalesInvoicePaymentDto(
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate, p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt)) p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt, p.PaymentMethod))
.ToListAsync(ct); .ToListAsync(ct);
} }
private static SalesInvoicePaymentDto Map(SalesInvoicePayment p) => new( private static SalesInvoicePaymentDto Map(SalesInvoicePayment p) => new(
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate, p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt); p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt, p.PaymentMethod);
} }
@@ -26,7 +26,9 @@ public sealed class SalesMappingService : ISalesMappingService
slip.TaxTotal, slip.TaxTotal,
slip.GrandTotal, slip.GrandTotal,
slip.PaidAmount, slip.PaidAmount,
slip.BalanceAmount); slip.BalanceAmount,
slip.CashAmount,
slip.CardAmount);
public SalesInvoiceDto MapInvoice(SalesInvoice invoice) public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
=> new( => new(
+11 -4
View File
@@ -142,7 +142,7 @@ public sealed class SalesSlipService : ISalesSlipService
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
Lines = lines Lines = lines
}; };
Recalculate(entity, request.PaidAmount); Recalculate(entity, request.PaidAmount, request.CardAmount);
await _slips.AddAsync(entity, token); await _slips.AddAsync(entity, token);
return entity; return entity;
}, ct); }, ct);
@@ -163,7 +163,7 @@ public sealed class SalesSlipService : ISalesSlipService
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
slip.Lines.Clear(); slip.Lines.Clear();
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line); foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
Recalculate(slip, request.PaidAmount); Recalculate(slip, request.PaidAmount, request.CardAmount);
slip.UpdatedAt = DateTime.UtcNow; slip.UpdatedAt = DateTime.UtcNow;
try try
{ {
@@ -249,9 +249,9 @@ public sealed class SalesSlipService : ISalesSlipService
/// GrandTotal, BalanceAmount == 0) — the behavior every slip had before partial payment /// GrandTotal, BalanceAmount == 0) — the behavior every slip had before partial payment
/// existed, so omitting it is a no-op change. Must be clamped against the just-recomputed /// existed, so omitting it is a no-op change. Must be clamped against the just-recomputed
/// GrandTotal, not the request's own line totals, since GrandTotal is only known after /// GrandTotal, not the request's own line totals, since GrandTotal is only known after
/// lines are summed here. /// lines are summed here. <paramref name="requestedCardAmount"/> null means all cash.
/// </summary> /// </summary>
private static void Recalculate(SalesSlip slip, decimal? requestedPaidAmount) private static void Recalculate(SalesSlip slip, decimal? requestedPaidAmount, decimal? requestedCardAmount)
{ {
slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice); slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice);
slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount); slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount);
@@ -263,8 +263,15 @@ public sealed class SalesSlipService : ISalesSlipService
throw new DomainException(ErrorCodes.SalesSlipPaidAmountInvalid, throw new DomainException(ErrorCodes.SalesSlipPaidAmountInvalid,
$"paidAmount must be between 0 and the slip's total {slip.GrandTotal}.", 400); $"paidAmount must be between 0 and the slip's total {slip.GrandTotal}.", 400);
var card = requestedCardAmount ?? 0m;
if (card < 0 || card > paid)
throw new DomainException(ErrorCodes.SalesCardAmountInvalid,
$"cardAmount must be between 0 and the paid amount {paid}.", 400);
slip.PaidAmount = paid; slip.PaidAmount = paid;
slip.BalanceAmount = slip.GrandTotal - paid; slip.BalanceAmount = slip.GrandTotal - paid;
slip.CardAmount = card;
slip.CashAmount = paid - card;
} }
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new( private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
@@ -28,6 +28,7 @@ public sealed class FifoCostingService : IFifoCostingService
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
/// <param name="lowStock">Records each issue for the after-request low-stock check; optional so callers outside a request (tests) can omit it.</param>
public FifoCostingService( public FifoCostingService(
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<JournalEntryStub> journal, ErpDbContext db, IRepository<JournalEntryStub> journal, ErpDbContext db,
@@ -312,6 +313,7 @@ public sealed class FifoCostingService : IFifoCostingService
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost)); segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
} }
_lowStock?.Track(itemId, warehouseId, qtyBase);
return segments; return segments;
} }

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