price adjustment and stock adjustments
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user