Merge pull request 'Fix/ui bug fixers rebase repair' (#108) from fix/ui-bug-fixers-rebase-repair into Dev

Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
2026-10-14 05:45:31 +00:00
264 changed files with 116592 additions and 1004 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));
}
}
+392
View File
@@ -0,0 +1,392 @@
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.Gl;
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 StubGlAccountConfigService(
(GlAccountRole.Inventory, "1200"),
(GlAccountRole.InventoryGain, "4900"),
(GlAccountRole.InventoryLoss, "5900")),
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();
}
}
+366
View File
@@ -0,0 +1,366 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests;
/// <summary>
/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO
/// SQL: the sales-line check (<see cref="SalesDomainService.ValidateLineTrackingAsync"/>) and the
/// shared stock-document rules (<see cref="SerialTrackingService"/>).
/// </summary>
public class ItemTrackingTests
{
private static readonly InMemoryDatabaseRoot Root = new();
private const int Wh1 = 1;
private const int Wh2 = 2;
private const int SerialItemId = 10;
private const int BatchItemId = 20;
private const int PlainItemId = 30;
// --- sales line validation --------------------------------------------------------------
[Fact]
public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
}
[Fact]
public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
}
[Fact]
public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock()
{
await using var db = await SeedAsync();
// D6: stock received before serial capture — a layer with no serial.
db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow });
await db.SaveChangesAsync();
var sales = CreateSalesDomain(db);
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet<int>());
Assert.Empty(serials);
}
[Fact]
public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
// Serial 3 is in stock at warehouse 2.
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
}
[Fact]
public async Task SalesLine_SoldSerial_IsNotAvailable()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
}
[Fact]
public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var documentSerials = new HashSet<int>();
await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials));
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
}
[Fact]
public async Task SalesLine_ValidSerials_AreReturned()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet<int>());
Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i));
}
[Fact]
public async Task SalesLine_UntrackedItem_RejectsSerials()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task SalesLine_ExpiredBatch_IsBlocked()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code);
}
[Fact]
public async Task SalesLine_BatchOfAnotherItem_IsRejected()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
// --- stock documents (transfers / adjustments / purchase returns) ------------------------
[Fact]
public async Task Outbound_DamagedSerial_OnlyWhenAllowed()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
// Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it...
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveOutboundAsync(
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock }));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
// ...a write-off or purchase return (InStock or Damaged) takes it.
var serials = await tracking.ResolveOutboundAsync(
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock, SerialStatus.Damaged });
Assert.Single(serials);
}
[Fact]
public async Task Inbound_SerialAlreadyInStock_IsADuplicate()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
item, Wh1, 1, new[] { "SN-1" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
}
[Fact]
public async Task Inbound_FractionalQty_IsRejected_ForSerialItems()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
// SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created.
var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet<string>());
await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow);
await db.SaveChangesAsync();
var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync();
Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status));
Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId));
Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ"));
}
[Fact]
public async Task Move_RecordsHistory_WithPreviousWarehouse()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var serial = await db.Serials.FirstAsync(s => s.SerialId == 1);
await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow);
await db.SaveChangesAsync();
var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1);
Assert.Equal(SerialStatus.InTransit, movement.Status);
Assert.Equal(Wh1, movement.FromWarehouseId);
Assert.Null(movement.WarehouseId);
Assert.Null(serial.WarehouseId);
}
// --- auto-generated serial numbers -------------------------------------------------------
[Fact]
public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers()
{
await using var db = await SeedAsync();
db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold });
// A draft GRN line already holds PH-0042 (not yet a Serial).
db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1,
Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } });
await db.SaveChangesAsync();
var tracking = new ItemTrackingService(db, null!);
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3);
Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next);
}
[Fact]
public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems()
{
await using var db = await SeedAsync();
db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock });
await db.SaveChangesAsync();
var tracking = new ItemTrackingService(db, null!);
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2);
Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next);
}
[Fact]
public async Task NextNumbers_RejectsTooMany()
{
await using var db = await SeedAsync();
var tracking = new ItemTrackingService(db, null!);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
// --- fixtures ---------------------------------------------------------------------------
private static async Task<ErpDbContext> SeedAsync()
{
var db = CreateContext();
db.Items.AddRange(
new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial },
new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch },
new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None });
db.Batches.AddRange(
new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) },
new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) });
db.Serials.AddRange(
new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 },
new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 },
new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 },
new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null },
new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 });
await db.SaveChangesAsync();
return db;
}
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));
}
// Pricing is never touched by the tracking check, so it is not wired.
private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new(
new Repository<Customer>(db), new Repository<Warehouse>(db), new Repository<User>(db),
new Repository<Item>(db), new Repository<TaxRate>(db), null!, new Repository<SalesConfig>(db),
new Repository<Batch>(db), new Repository<Serial>(db), new Repository<StockLayer>(db),
CreateSerialTracking(db));
private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new(
new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), new TestCurrentUser());
private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled)
{
db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled });
await db.SaveChangesAsync();
}
// --- Product Configuration "Serial numbers" switch ---------------------------------------
[Fact]
public async Task SwitchOff_SerialItemSells_WithoutSerials()
{
await using var db = await SeedAsync();
await SwitchSerialNumbersAsync(db, enabled: false);
var sales = CreateSalesDomain(db);
// No pre-tracking stock exists, yet nothing is asked: the item is plain stock now.
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet<int>());
Assert.Empty(serials);
}
[Fact]
public async Task SwitchOff_SerialsSent_AreRefused()
{
await using var db = await SeedAsync();
await SwitchSerialNumbersAsync(db, enabled: false);
var sales = CreateSalesDomain(db);
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var sale = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
var adjustment = await Assert.ThrowsAsync<DomainException>(() =>
tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code);
Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code);
}
[Fact]
public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing()
{
await using var db = await SeedAsync();
Assert.True(await CreateSerialTracking(db).IsEnabledAsync());
}
}
@@ -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,172 @@
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>Not implemented on purpose — nothing here picks a GL account by code; see
/// <see cref="StubGeneralLedgerService"/> for the fake that does.</summary>
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) => throw new NotSupportedException();
}
/// <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));
}
@@ -0,0 +1,41 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9).</summary>
[Route("api/v1/serials")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class SerialsController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public SerialsController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SerialSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SerialSummaryDto>>> List(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct));
/// <summary>Exact lookup by serial number or barcode, across items.</summary>
[HttpGet("lookup")]
[ProducesResponseType(typeof(IReadOnlyList<SerialDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SerialDetailDto>>> Lookup([FromQuery] string serialNo, CancellationToken ct)
=> Ok(await _tracking.FindSerialAsync(serialNo, ct));
[HttpGet("{serialId:int}")]
[ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SerialDetailDto>> Get(int serialId, CancellationToken ct)
{
var dto = await _tracking.GetSerialAsync(serialId, ct);
return dto is null ? NotFound() : Ok(dto);
}
}
@@ -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));
} }
+14 -1
View File
@@ -15,13 +15,26 @@ public sealed class StockController : ApiControllerBase
{ {
private readonly IStockService _stock; private readonly IStockService _stock;
private readonly IReorderService _reorder; private readonly IReorderService _reorder;
private readonly IItemTrackingService _tracking;
public StockController(IStockService stock, IReorderService reorder) public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
{ {
_stock = stock; _stock = stock;
_reorder = reorder; _reorder = reorder;
_tracking = tracking;
} }
/// <summary>
/// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry
/// first. <c>expiringWithinDays</c> keeps batches expiring within that window, expired included.
/// </summary>
[HttpGet("batches")]
[ProducesResponseType(typeof(PagedResponse<BatchStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BatchStockDto>>> Batches(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct));
[HttpGet("on-hand")] [HttpGet("on-hand")]
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Serial/batch pickers used across GRN, sales, returns and stock screens
/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need
/// only ERP access — not one module's nav permission — as every one of those screens calls them.
/// </summary>
[Route("api/v1/tracking")]
public sealed class TrackingController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
private readonly ISerialTrackingService _serialTracking;
public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking)
{
_tracking = tracking;
_serialTracking = serialTracking;
}
/// <summary>
/// Which tracking features are switched on (Product Configuration) — readable by every
/// screen, unlike <c>/product-config</c> which needs the Products permission.
/// </summary>
[HttpGet("settings")]
[ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<TrackingSettingsDto>> Settings(CancellationToken ct)
=> Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct)));
/// <summary>
/// Auto-generate: the next <c>count</c> free serial numbers for an item under <c>prefix</c>
/// (continues the highest number already used). Nothing is reserved — the GRN still checks
/// uniqueness when it is saved and confirmed.
/// </summary>
[HttpGet("serials/next-numbers")]
[ProducesResponseType(typeof(IReadOnlyList<string>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<string>>> NextSerialNumbers(
[FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default)
=> Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct));
/// <summary>Issuable serials of an item at a warehouse (search by number/barcode with <c>q</c>).</summary>
[HttpGet("serials/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableSerialDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableSerialDto>>> AvailableSerials(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default)
=> Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct));
/// <summary>Issuable stock per batch with expiry, soonest expiry first.</summary>
[HttpGet("batches/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableBatchDto>>> AvailableBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct));
/// <summary>The FEFO pick posting would make for <c>qty</c> when no batch is chosen.</summary>
[HttpGet("batches/suggest")]
[ProducesResponseType(typeof(IReadOnlyList<BatchSuggestionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<BatchSuggestionDto>>> SuggestBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct)
=> Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct));
}
@@ -65,4 +65,15 @@ public sealed class VendorsController : ApiControllerBase
await _vendors.SetStatusAsync(vendorId, request.Status, ct); await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{vendorId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int vendorId, CancellationToken ct)
{
await _vendors.DeleteAsync(vendorId, ct);
return NoContent();
}
} }
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by
/// posting a sale, never directly — this controller only reads.
/// </summary>
[Route("api/v1/warranties")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class WarrantiesController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarrantyRegistrationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarrantyRegistrationDto>>> List(
[FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo,
[FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, 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; }
}
@@ -65,4 +65,12 @@ public class GrnLine
/// Empty for a non-warranty item. /// Empty for a non-warranty item.
/// </summary> /// </summary>
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>(); public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
/// <summary>
/// One row per received unit when <see cref="Item.TrackingMode"/> is
/// <see cref="TrackingMode.Serial"/> — count must equal <see cref="Qty"/>. For a serial item
/// under warranty the serial is the warranty identity, so it carries no
/// <see cref="WarrantyNumbers"/>. Empty otherwise.
/// </summary>
public ICollection<GrnLineSerial> Serials { get; set; } = new List<GrnLineSerial>();
} }
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A serial number captured on a Draft GRN line for a serial-tracked item
/// (<see cref="Item.TrackingMode"/> = <see cref="Enums.TrackingMode.Serial"/>). The line must
/// carry exactly <see cref="GrnLine.Qty"/> of these. The draft holds the numbers only — the
/// <see cref="Serial"/> rows (and their qty-1 FIFO layers) are created when the GRN is
/// confirmed, so a draft that is never confirmed reserves nothing (docs/ITEM_TRACKING_PLAN.md Phase 2).
/// </summary>
public class GrnLineSerial
{
public int GrnLineSerialId { get; set; }
public int GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public string SerialNo { get; set; } = string.Empty;
/// <summary>Set on confirm — the <see cref="Serial"/> created for this number.</summary>
public int? SerialId { get; set; }
public Serial? Serial { 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; }
}
@@ -19,6 +19,13 @@ public class SalesInvoiceLine
public int WarehouseId { get; set; } public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
/// <summary>Pins the line to one batch; null lets posting pick batches FEFO.</summary>
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
/// <summary>Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).</summary>
public ICollection<SalesInvoiceLineSerial> Serials { get; set; } = new List<SalesInvoiceLineSerial>();
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; } public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty; public string PriceSource { get; set; } = string.Empty;
@@ -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>
@@ -0,0 +1,35 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// What a posted sales line actually issued: one row per consumed FIFO segment, carrying the
/// batch (FEFO-picked when the line named none) and, for serial items, the serial. The source
/// line is referenced polymorphically (<see cref="SourceDocType"/> is a
/// <see cref="Domain.DocumentTypes"/> value) — same convention as <see cref="StockLedger"/> —
/// so invoices, slips and bundles share one table. Returns read it to restock into the same
/// batch/serial (docs/ITEM_TRACKING_PLAN.md Phases 4 and 6).
/// </summary>
public class SalesLineAllocation
{
public int SalesLineAllocationId { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public int SourceLineId { get; set; }
public int ItemId { get; set; }
public int WarehouseId { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
public decimal Qty { get; set; }
public decimal UnitCost { get; set; }
/// <summary>Quantity of this allocation already taken back by sales returns.</summary>
public decimal QtyReturned { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,29 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A serial chosen on a Draft sales-invoice line for a serial-tracked item — one row per unit
/// (count = Qty + FreeQty). Choosing does not reserve: posting re-checks each serial is still
/// in stock and consumes exactly its layer (docs/ITEM_TRACKING_PLAN.md Phase 4, D4).
/// </summary>
public class SalesInvoiceLineSerial
{
public int SalesInvoiceLineSerialId { get; set; }
public int SalesInvoiceLineId { get; set; }
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
}
/// <summary>Sales-slip counterpart of <see cref="SalesInvoiceLineSerial"/>.</summary>
public class SalesSlipLineSerial
{
public int SalesSlipLineSerialId { get; set; }
public int SalesSlipLineId { get; set; }
public SalesSlipLine? SalesSlipLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
}
@@ -2,7 +2,9 @@ namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// Sales-return line referencing the original sales invoice line for traceability. /// Sales-return line referencing the original sales invoice line for traceability.
/// <see cref="Qty"/> is in base UOM. /// <see cref="Qty"/> is in base UOM. <see cref="BatchId"/> is the batch the goods are restocked
/// into — the batch they were sold from (docs/ITEM_TRACKING_PLAN.md Phase 6); <see cref="Serials"/>
/// lists the returned units of a serial-tracked item.
/// </summary> /// </summary>
public class SalesReturnLine public class SalesReturnLine
{ {
@@ -18,4 +20,21 @@ public class SalesReturnLine
public Item? Item { get; set; } public Item? Item { get; set; }
public decimal Qty { get; set; } public decimal Qty { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public ICollection<SalesReturnLineSerial> Serials { get; set; } = new List<SalesReturnLineSerial>();
}
/// <summary>One serial taken back on a <see cref="SalesReturnLine"/>.</summary>
public class SalesReturnLineSerial
{
public int SalesReturnLineSerialId { get; set; }
public int ReturnLineId { get; set; }
public SalesReturnLine? ReturnLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
} }
@@ -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; }
@@ -22,9 +22,16 @@ public class SalesSlipLine
public int? BatchId { get; set; } public int? BatchId { get; set; }
public Batch? Batch { get; set; } public Batch? Batch { get; set; }
/// <summary>
/// Legacy single-serial pin, from before a line could carry several. Kept for old drafts and
/// still set when exactly one serial is chosen; <see cref="Serials"/> is authoritative.
/// </summary>
public int? SerialId { get; set; } public int? SerialId { get; set; }
public Serial? Serial { get; set; } public Serial? Serial { get; set; }
/// <summary>Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).</summary>
public ICollection<SalesSlipLineSerial> Serials { get; set; } = new List<SalesSlipLineSerial>();
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; } public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty; public string PriceSource { get; set; } = string.Empty;
+32 -2
View File
@@ -1,8 +1,13 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04). /// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
/// Model: docs/10 Part C.4. /// Created when the GRN that received it is confirmed; each in-stock serial owns exactly one
/// FIFO layer of qty 1 (<see cref="StockLayer.SerialId"/>), so issuing a serial consumes that
/// layer. <see cref="WarehouseId"/>/<see cref="BinId"/> are its current location and are
/// cleared once it leaves stock. Model: docs/10 Part C.4, docs/ITEM_TRACKING_PLAN.md Phase 1.
/// </summary> /// </summary>
public class Serial public class Serial
{ {
@@ -12,8 +17,33 @@ public class Serial
public Item? Item { get; set; } public Item? Item { get; set; }
public string SerialNo { get; set; } = string.Empty; public string SerialNo { get; set; } = string.Empty;
public string Status { get; set; } = "InStock"; public SerialStatus Status { get; set; } = SerialStatus.InStock;
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary> /// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
public string? Barcode { get; set; } public string? Barcode { get; set; }
/// <summary>Current warehouse while in stock; null once sold/returned to vendor/scrapped.</summary>
public int? WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
/// <summary>The GRN line that received this unit (provenance); null for count/adjustment finds.</summary>
public int? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public DateTime? ReceivedAt { get; set; }
/// <summary>
/// Warranty coverage snapshotted from <see cref="Item.WarrantyPeriodMonths"/> at receipt
/// (same rule as <see cref="GrnLineWarrantyNumber.WarrantyPeriodMonths"/>); null for a
/// non-warranty item. The clock does not start here — it starts at sale
/// (<see cref="WarrantyRegistration"/>).
/// </summary>
public int? WarrantyPeriodMonths { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
} }
@@ -0,0 +1,46 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Append-only history of one serial: a row each time a document changes its status or
/// location (receipt, sale, return, transfer, adjustment, count). <see cref="Status"/> and
/// <see cref="WarehouseId"/> are the serial's state <em>after</em> the movement. The source
/// document is referenced polymorphically, as on <see cref="StockLedger"/>. This is where serial
/// history is read from — the stock ledger keeps one row per document line, not per unit
/// (docs/ITEM_TRACKING_PLAN.md Phases 2 and 9).
/// </summary>
public class SerialMovement
{
public int SerialMovementId { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public SerialStatus Status { get; set; }
public int? FromWarehouseId { get; set; }
public int? WarehouseId { get; set; }
public int UserId { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// Records <paramref name="serial"/>'s current state — call after updating it. Linked by
/// navigation, so it works for a serial inserted in the same save.
/// </summary>
public static SerialMovement Of(Serial serial, int? fromWarehouseId, string sourceDocType, int sourceDocId, int userId, DateTime at) => new()
{
Serial = serial,
SerialId = serial.SerialId,
SourceDocType = sourceDocType,
SourceDocId = sourceDocId,
Status = serial.Status,
FromWarehouseId = fromWarehouseId,
WarehouseId = serial.WarehouseId,
UserId = userId,
CreatedAt = at
};
}
@@ -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>();
} }
@@ -18,6 +18,15 @@ public class StockAdjustmentLine
public int? BinId { get; set; } public int? BinId { get; set; }
public int? BatchId { get; set; } public int? BatchId { get; set; }
public int? SerialId { get; set; } public int? SerialId { 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; }
} }
@@ -32,4 +32,27 @@ public class StockTransferLine
/// <summary>Quantity already received at the destination (partial-receive support).</summary> /// <summary>Quantity already received at the destination (partial-receive support).</summary>
public decimal QtyReceived { get; set; } public decimal QtyReceived { get; set; }
/// <summary>The units moved, for a serial-tracked item (one per unit of <see cref="Qty"/>).</summary>
public ICollection<StockTransferLineSerial> Serials { get; set; } = new List<StockTransferLineSerial>();
}
/// <summary>
/// One serial on a transfer line. <see cref="UnitCost"/>/<see cref="BatchId"/> are captured from
/// its own source layer at dispatch, so the destination layer is recreated exactly;
/// <see cref="ReceivedAt"/> is set when it arrives (partial receive names which serials).
/// </summary>
public class StockTransferLineSerial
{
public int StockTransferLineSerialId { get; set; }
public int TransferLineId { get; set; }
public StockTransferLine? TransferLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
public int? BatchId { get; set; }
public decimal? UnitCost { get; set; }
public DateTime? ReceivedAt { get; set; }
} }
@@ -0,0 +1,42 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A customer's warranty, started when the sale that issued the goods is posted
/// (docs/ITEM_TRACKING_PLAN.md Phase 5, D3/D7) — stock waiting in a warehouse never consumes
/// warranty. For a serial item there is one row per serial (<see cref="SerialId"/> set,
/// <see cref="Qty"/> = 1); for a warranty item that is not serial-tracked there is one row per
/// sales line covering its <see cref="Qty"/>. <see cref="PeriodMonths"/> is the coverage
/// snapshotted at receipt (<see cref="Serial.WarrantyPeriodMonths"/>), else the item's period.
/// </summary>
public class WarrantyRegistration
{
public int WarrantyRegistrationId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public int SourceLineId { get; set; }
public decimal Qty { get; set; }
public DateOnly StartDate { get; set; }
public int PeriodMonths { get; set; }
public DateOnly EndDate { get; set; }
public WarrantyStatus Status { get; set; } = WarrantyStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { 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
}
@@ -0,0 +1,25 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle state of one serial-tracked unit (FR-WH-04). Stored as a string — the values
/// <c>InStock</c>/<c>Sold</c> written before this enum existed stay valid as-is.
/// Only <see cref="InStock"/> is issuable.
/// </summary>
public enum SerialStatus
{
InStock,
Reserved,
/// <summary>Dispatched on a stock transfer, not yet received — in no warehouse.</summary>
InTransit,
Sold,
ReturnedByCustomer,
ReturnedToVendor,
Damaged,
Scrapped,
/// <summary>
/// Left stock without being named, while serial numbers were switched off in Product
/// Configuration — the unit was issued oldest-first like untracked stock, so which
/// document took it is not recorded against the serial.
/// </summary>
IssuedUntracked
}
@@ -0,0 +1,10 @@
namespace ERPCore.Domain.Enums;
/// <summary>State of a <see cref="Entities.WarrantyRegistration"/>. Stored as a string.</summary>
public enum WarrantyStatus
{
Active,
/// <summary>The unit came back on a sales return — the warranty no longer applies.</summary>
Voided,
Claimed
}
@@ -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);
+12 -2
View File
@@ -7,12 +7,16 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths); public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
/// <summary>A serial captured on a GRN line; <see cref="SerialId"/> is set once the GRN is confirmed.</summary>
public sealed record GrnLineSerialDto(string SerialNo, int? SerialId);
public sealed record GrnLineDto( public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int? BinId, int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice, decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers); HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers,
string? BatchNo, DateOnly? BatchExpiryDate, IReadOnlyList<GrnLineSerialDto> Serials);
public sealed record GrnDto( public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
@@ -70,9 +74,15 @@ public sealed class CreateGrnLineInput
public BatchInput? Batch { get; set; } public BatchInput? Batch { get; set; }
/// <summary> /// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is /// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise. /// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>) and not serial-tracked —
/// a serial item's serial is its warranty identity. Ignored otherwise.
/// </summary> /// </summary>
public List<string>? WarrantyNumbers { get; set; } public List<string>? WarrantyNumbers { get; set; }
/// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>, so Qty must be whole), when the
/// item is serial-tracked (<c>Item.TrackingMode == Serial</c>). Rejected for any other item.
/// </summary>
public List<string>? SerialNumbers { get; set; }
} }
public sealed class CreateGrnRequest public sealed class CreateGrnRequest
@@ -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
@@ -31,6 +31,8 @@ public sealed class CreatePurchaseInvoicePaymentRequest
[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; }
[StringLength(100)] public string? Reference { get; set; } [StringLength(100)] public string? Reference { get; set; }
/// <summary>When the vendor was paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
} }
public sealed class CreatePurchaseInvoiceRequest public sealed class CreatePurchaseInvoiceRequest
@@ -25,6 +25,12 @@ public sealed class CreatePurchaseReturnLineInput
public int? GrnLineId { get; set; } public int? GrnLineId { get; set; }
[Required] public int ItemId { get; set; } [Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// Serials sent back, for a serial-tracked item — one per unit, each at the return warehouse
/// (InStock or Damaged; for a rejected GRN line, received on that line). They become
/// <c>ReturnedToVendor</c>. Rejected for any other item.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreatePurchaseReturnRequest public sealed class CreatePurchaseReturnRequest
@@ -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; }
} }
+18 -2
View File
@@ -7,7 +7,11 @@ public sealed record SalesInvoiceLineDto(
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId,
int? BatchId, IReadOnlyList<SalesLineSerialDto> Serials);
/// <summary>A serial chosen on (or sold by) a sales line.</summary>
public sealed record SalesLineSerialDto(int SerialId, string SerialNo);
public sealed record SalesInvoiceTotalsDto( public sealed record SalesInvoiceTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
@@ -27,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; }
@@ -43,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>
@@ -95,6 +104,13 @@ public sealed class CreateSalesInvoiceLineRequest
[Range(0, 100)] public decimal? TaxPct { get; set; } [Range(0, 100)] public decimal? TaxPct { get; set; }
public bool IsFreeIssue { get; set; } public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; } public int? ParentLineId { get; set; }
/// <summary>Pins the line to one batch (must belong to the item, not expired). Omit to let posting pick FEFO.</summary>
public int? BatchId { get; set; }
/// <summary>
/// Serials sold on this line — required for a serial-tracked item, one per unit of
/// Qty + FreeQty, each InStock at the line warehouse. Rejected for any other item.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateSalesInvoiceRequest public sealed class CreateSalesInvoiceRequest
+28 -2
View File
@@ -5,7 +5,17 @@ namespace ERPCore.Dtos.Sales;
// Responses ----------------------------------------------------------------- // Responses -----------------------------------------------------------------
public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty); public sealed record SalesReturnLineDto(
int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty,
int? BatchId, IReadOnlyList<SalesLineSerialDto> Serials);
/// <summary>
/// What one posted sales invoice line issued, net of returns — the batches/serials a return
/// against it can take back.
/// </summary>
public sealed record SalesLineAllocationDto(
int? BatchId, string? BatchNo, DateOnly? ExpiryDate, int? SerialId, string? SerialNo,
decimal Qty, decimal QtyReturned);
public sealed record SalesReturnDto( public sealed record SalesReturnDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
@@ -22,7 +32,8 @@ public sealed record SalesReturnSummaryDto(
/// whatever has already been returned against it. The invoice line's own <c>Qty</c> /// whatever has already been returned against it. The invoice line's own <c>Qty</c>
/// is never mutated by a return, so this is computed on read from return history. /// is never mutated by a return, so this is computed on read from return history.
/// </summary> /// </summary>
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty); public sealed record SalesInvoiceLineRemainingDto(
int SalesInvoiceLineId, decimal RemainingQty, IReadOnlyList<SalesLineAllocationDto> Allocations);
// Requests -------------------------------------------------------------------- // Requests --------------------------------------------------------------------
@@ -32,6 +43,16 @@ public sealed class CreateSalesReturnLineInput
public int? SalesInvoiceLineId { get; set; } public int? SalesInvoiceLineId { get; set; }
[Required] public int ItemId { get; set; } [Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// Batch to restock into. Optional when the invoice line issued a single batch (it is used);
/// required for a batch-tracked item otherwise, and must be one the line issued.
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// Returned serials of a serial-tracked item — one per unit, each Sold (and, with an invoice
/// line, sold on that line). Rejected for any other item.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateSalesReturnRequest public sealed class CreateSalesReturnRequest
@@ -40,5 +61,10 @@ public sealed class CreateSalesReturnRequest
[Required] public int WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary> /// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public int? ReasonCodeId { get; set; } public int? ReasonCodeId { get; set; }
/// <summary>
/// Returned serials become <c>Damaged</c> (not resaleable) instead of <c>InStock</c>. Omit to
/// derive it from the reason: <c>DEF</c> (Defective) and <c>QREJ</c> (Quality Reject) mean damaged.
/// </summary>
public bool? MarkDamaged { get; set; }
[Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new();
} }
+15 -3
View File
@@ -8,11 +8,11 @@ public sealed record SalesSlipLineDto(
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId, decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId,
int? BatchId, int? SerialId); int? BatchId, int? SerialId, IReadOnlyList<SalesLineSerialDto> Serials);
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,
@@ -62,8 +62,13 @@ public sealed class CreateSalesSlipLineRequest
public int? ParentLineId { get; set; } public int? ParentLineId { get; set; }
/// <summary>Pins the line to a specific batch (must belong to <see cref="ItemId"/>), consumed via FIFO.</summary> /// <summary>Pins the line to a specific batch (must belong to <see cref="ItemId"/>), consumed via FIFO.</summary>
public int? BatchId { get; set; } public int? BatchId { get; set; }
/// <summary>Pins the line to a specific serial (must belong to <see cref="ItemId"/>); marked Sold on posting.</summary> /// <summary>Legacy single-serial pin — treated as <c>SerialIds = [SerialId]</c> when <see cref="SerialIds"/> is empty.</summary>
public int? SerialId { get; set; } public int? SerialId { get; set; }
/// <summary>
/// Serials sold on this line — required for a serial-tracked item, one per unit of
/// Qty + FreeQty, each InStock at the line warehouse. Marked Sold on posting.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateSalesSlipRequest public sealed class CreateSalesSlipRequest
@@ -82,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
@@ -93,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; }
} }
+25 -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); /// <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 ----------------------------------------------------------------------
@@ -26,6 +38,16 @@ public sealed class CreateAdjustmentLineInput
public int? BatchId { get; set; } public int? BatchId { get; set; }
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary> /// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
public decimal QtyDelta { get; set; } public decimal QtyDelta { get; set; }
/// <summary>
/// Negative delta on a serial-tracked item: the serials written off (InStock or Damaged at
/// the warehouse, one per unit) — they become <c>Scrapped</c>.
/// </summary>
public List<int>? SerialIds { get; set; }
/// <summary>
/// Positive delta on a serial-tracked item: the serial numbers found (one per unit) — new
/// numbers are created, a known serial that is out of stock is brought back InStock.
/// </summary>
public List<string>? SerialNos { get; set; }
} }
public sealed class CreateAdjustmentRequest public sealed class CreateAdjustmentRequest
+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,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Item tracking lookups and registers (docs/ITEM_TRACKING_PLAN.md Phases 5, 8, 9).
/// <summary>Product Configuration tracking switches, as every screen needs them.</summary>
public sealed record TrackingSettingsDto(bool SerialNumbersEnabled);
/// <summary>An issuable serial, for sale/transfer/return pickers.</summary>
public sealed record AvailableSerialDto(int SerialId, string SerialNo, int? BatchId, string? BatchNo, SerialStatus Status);
/// <summary>Issuable stock of one batch at a warehouse (BatchId null = unbatched stock).</summary>
public sealed record AvailableBatchDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry);
/// <summary>A FEFO pick: <see cref="Qty"/> of <see cref="BatchId"/>.</summary>
public sealed record BatchSuggestionDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty);
public sealed record SerialSummaryDto(
int SerialId, int ItemId, string Sku, string ItemName, string SerialNo, SerialStatus Status,
int? WarehouseId, string? WarehouseName, int? BinId, DateTime? ReceivedAt,
int? GrnId, string? GrnDocNo, int? WarrantyPeriodMonths);
public sealed record SerialMovementDto(
int SerialMovementId, string SourceDocType, int SourceDocId, SerialStatus Status,
int? FromWarehouseId, int? WarehouseId, int UserId, DateTime CreatedAt);
public sealed record SerialDetailDto(
SerialSummaryDto Serial, IReadOnlyList<SerialMovementDto> History, IReadOnlyList<WarrantyRegistrationDto> Warranties);
public sealed record WarrantyRegistrationDto(
int WarrantyRegistrationId, int ItemId, string Sku, string ItemName, int? SerialId, string? SerialNo,
int CustomerId, string CustomerName, string SourceDocType, int SourceDocId, int SourceLineId,
decimal Qty, DateOnly StartDate, int PeriodMonths, DateOnly EndDate, WarrantyStatus Status,
int DaysRemaining, bool IsExpired);
/// <summary>Stock on hand per batch (with expiry), for the batch / expiry report.</summary>
public sealed record BatchStockDto(
int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseName,
int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry, bool IsExpired);
+13 -1
View File
@@ -6,7 +6,11 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.4) ------------------------------------------------------ // Responses (docs/11 §5.4) ------------------------------------------------------
public sealed record TransferLineDto( public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived); int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived,
IReadOnlyList<TransferLineSerialDto> Serials);
/// <summary>A serial on a transfer line; <see cref="Received"/> once it has arrived.</summary>
public sealed record TransferLineSerialDto(int SerialId, string SerialNo, bool Received);
public sealed record TransferDto( public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId, int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
@@ -34,8 +38,11 @@ public sealed class CreateTransferLineInput
[Required] public int ItemId { get; set; } [Required] public int ItemId { get; set; }
public int? SrcBinId { get; set; } public int? SrcBinId { get; set; }
public int? DestBinId { get; set; } public int? DestBinId { get; set; }
/// <summary>Batch to move. Omitted for a batch-tracked item, the line is split FEFO across batches.</summary>
public int? BatchId { get; set; } public int? BatchId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>Serials to move — required for a serial-tracked item, one per unit, each InStock at the source.</summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateTransferRequest public sealed class CreateTransferRequest
@@ -52,6 +59,11 @@ public sealed class ReceiveTransferLineInput
{ {
[Required] public int TransferLineId { get; set; } [Required] public int TransferLineId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// For a serial line: which in-transit serials arrived (count = Qty). May be omitted when
/// receiving everything still outstanding on the line.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class ReceiveTransferRequest public sealed class ReceiveTransferRequest
@@ -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);
}
}
@@ -34,7 +34,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
builder.HasKey(s => s.SerialId); builder.HasKey(s => s.SerialId);
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.Property(s => s.Status).IsRequired().HasMaxLength(20); // String-stored enum: rows written as "InStock"/"Sold" before SerialStatus existed
// read back unchanged.
builder.Property(s => s.Status).HasConversion<string>().IsRequired().HasMaxLength(20);
builder.Property(s => s.Barcode).HasMaxLength(100); builder.Property(s => s.Barcode).HasMaxLength(100);
builder.HasIndex(s => s.Barcode).IsUnique(); builder.HasIndex(s => s.Barcode).IsUnique();
@@ -43,7 +45,12 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
.WithMany() .WithMany()
.HasForeignKey(s => s.ItemId) .HasForeignKey(s => s.ItemId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.HasOne(s => s.Warehouse).WithMany().HasForeignKey(s => s.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(s => s.Bin).WithMany().HasForeignKey(s => s.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(s => s.GrnLine).WithMany().HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique(); builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique();
builder.HasIndex(s => new { s.ItemId, s.Status });
builder.HasIndex(s => new { s.WarehouseId, s.ItemId, s.Status });
} }
} }
@@ -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);
}
}
@@ -74,3 +74,23 @@ public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguratio
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique(); builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
} }
} }
public sealed class GrnLineSerialConfiguration : IEntityTypeConfiguration<GrnLineSerial>
{
public void Configure(EntityTypeBuilder<GrnLineSerial> builder)
{
builder.ToTable("grn_line_serials");
builder.HasKey(s => s.GrnLineSerialId);
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.HasOne(s => s.GrnLine).WithMany(l => l.Serials)
.HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(s => s.Serial).WithMany()
.HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict);
// Global uniqueness lives on serials (ItemId, SerialNo); this only stops a number
// being entered twice on one line.
builder.HasIndex(s => new { s.GrnLineId, s.SerialNo }).IsUnique();
}
}
@@ -0,0 +1,118 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
// Sales-side serial/batch tracking and warranty (docs/ITEM_TRACKING_PLAN.md Phases 4–5).
public sealed class SalesInvoiceLineSerialConfiguration : IEntityTypeConfiguration<SalesInvoiceLineSerial>
{
public void Configure(EntityTypeBuilder<SalesInvoiceLineSerial> builder)
{
builder.ToTable("sales_invoice_line_serials");
builder.HasKey(x => x.SalesInvoiceLineSerialId);
builder.HasOne(x => x.SalesInvoiceLine).WithMany(l => l.Serials)
.HasForeignKey(x => x.SalesInvoiceLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Serial).WithMany()
.HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.SalesInvoiceLineId, x.SerialId }).IsUnique();
}
}
public sealed class SalesSlipLineSerialConfiguration : IEntityTypeConfiguration<SalesSlipLineSerial>
{
public void Configure(EntityTypeBuilder<SalesSlipLineSerial> builder)
{
builder.ToTable("sales_slip_line_serials");
builder.HasKey(x => x.SalesSlipLineSerialId);
builder.HasOne(x => x.SalesSlipLine).WithMany(l => l.Serials)
.HasForeignKey(x => x.SalesSlipLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Serial).WithMany()
.HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.SalesSlipLineId, x.SerialId }).IsUnique();
}
}
public sealed class SalesLineAllocationConfiguration : IEntityTypeConfiguration<SalesLineAllocation>
{
public void Configure(EntityTypeBuilder<SalesLineAllocation> builder)
{
builder.ToTable("sales_line_allocations");
builder.HasKey(x => x.SalesLineAllocationId);
builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.QtyReturned).HasPrecision(18, 4);
builder.Property(x => x.UnitCost).HasPrecision(18, 6);
builder.HasOne(x => x.Batch).WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId, x.SourceLineId });
builder.HasIndex(x => x.SerialId);
builder.HasIndex(x => x.BatchId);
}
}
public sealed class StockTransferLineSerialConfiguration : IEntityTypeConfiguration<StockTransferLineSerial>
{
public void Configure(EntityTypeBuilder<StockTransferLineSerial> builder)
{
builder.ToTable("stock_transfer_line_serials");
builder.HasKey(x => x.StockTransferLineSerialId);
builder.Property(x => x.UnitCost).HasPrecision(18, 6);
builder.HasOne(x => x.TransferLine).WithMany(l => l.Serials)
.HasForeignKey(x => x.TransferLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Serial).WithMany()
.HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.TransferLineId, x.SerialId }).IsUnique();
}
}
public sealed class SerialMovementConfiguration : IEntityTypeConfiguration<SerialMovement>
{
public void Configure(EntityTypeBuilder<SerialMovement> builder)
{
builder.ToTable("serial_movements");
builder.HasKey(x => x.SerialMovementId);
builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50);
builder.Property(x => x.Status).HasConversion<string>().IsRequired().HasMaxLength(20);
builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => new { x.SerialId, x.SerialMovementId });
builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId });
}
}
public sealed class WarrantyRegistrationConfiguration : IEntityTypeConfiguration<WarrantyRegistration>
{
public void Configure(EntityTypeBuilder<WarrantyRegistration> builder)
{
builder.ToTable("warranty_registrations");
builder.HasKey(x => x.WarrantyRegistrationId);
builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.Status).HasConversion<string>().IsRequired().HasMaxLength(20);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => x.SerialId);
builder.HasIndex(x => x.CustomerId);
builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId });
builder.HasIndex(x => new { x.Status, x.EndDate });
}
}
@@ -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();
@@ -111,6 +114,11 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
.HasForeignKey(x => x.WarehouseId) .HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Batch)
.WithMany()
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50); builder.Property(x => x.PriceSource).HasMaxLength(50);
} }
} }
@@ -37,5 +37,20 @@ public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration<Sale
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict);
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);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class SalesReturnLineSerialConfiguration : IEntityTypeConfiguration<SalesReturnLineSerial>
{
public void Configure(EntityTypeBuilder<SalesReturnLineSerial> builder)
{
builder.ToTable("sales_return_line_serials");
builder.HasKey(s => s.SalesReturnLineSerialId);
builder.HasOne(s => s.ReturnLine).WithMany(l => l.Serials).HasForeignKey(s => s.ReturnLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(s => s.Serial).WithMany().HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.ReturnLineId, s.SerialId }).IsUnique();
} }
} }
@@ -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,11 +38,13 @@ 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);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).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,9 +90,33 @@ 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>();
public DbSet<GrnLineSerial> GrnLineSerials => Set<GrnLineSerial>();
public DbSet<SalesInvoiceLineSerial> SalesInvoiceLineSerials => Set<SalesInvoiceLineSerial>();
public DbSet<SalesSlipLineSerial> SalesSlipLineSerials => Set<SalesSlipLineSerial>();
public DbSet<SalesLineAllocation> SalesLineAllocations => Set<SalesLineAllocation>();
public DbSet<WarrantyRegistration> WarrantyRegistrations => Set<WarrantyRegistration>();
public DbSet<SalesReturnLineSerial> SalesReturnLineSerials => Set<SalesReturnLineSerial>();
public DbSet<SerialMovement> SerialMovements => Set<SerialMovement>();
public DbSet<StockTransferLineSerial> StockTransferLineSerials => Set<StockTransferLineSerial>();
// --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) --- // --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) ---
public DbSet<StockLayer> StockLayers => Set<StockLayer>(); public DbSet<StockLayer> StockLayers => Set<StockLayer>();
@@ -213,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,635 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemTrackingSerialsWarranty : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BinId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "serials",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<int>(
name: "GrnLineId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ReceivedAt",
table: "serials",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<uint>(
name: "RowVersion",
table: "serials",
type: "int unsigned",
nullable: false,
defaultValue: 0u);
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "serials",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WarehouseId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WarrantyPeriodMonths",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BatchId",
table: "sales_return_lines",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BatchId",
table: "sales_invoice_lines",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "grn_line_serials",
columns: table => new
{
GrnLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
GrnLineId = table.Column<int>(type: "int", nullable: false),
SerialNo = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
SerialId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_line_serials", x => x.GrnLineSerialId);
table.ForeignKey(
name: "FK_grn_line_serials_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_grn_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_invoice_line_serials",
columns: table => new
{
SalesInvoiceLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SalesInvoiceLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_invoice_line_serials", x => x.SalesInvoiceLineSerialId);
table.ForeignKey(
name: "FK_sales_invoice_line_serials_sales_invoice_lines_SalesInvoiceL~",
column: x => x.SalesInvoiceLineId,
principalTable: "sales_invoice_lines",
principalColumn: "SalesInvoiceLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_invoice_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_line_allocations",
columns: table => new
{
SalesLineAllocationId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
SourceLineId = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<int>(type: "int", nullable: false),
WarehouseId = table.Column<int>(type: "int", nullable: false),
BatchId = table.Column<int>(type: "int", nullable: true),
SerialId = table.Column<int>(type: "int", nullable: true),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
QtyReturned = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_line_allocations", x => x.SalesLineAllocationId);
table.ForeignKey(
name: "FK_sales_line_allocations_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_line_allocations_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_return_line_serials",
columns: table => new
{
SalesReturnLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ReturnLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_return_line_serials", x => x.SalesReturnLineSerialId);
table.ForeignKey(
name: "FK_sales_return_line_serials_sales_return_lines_ReturnLineId",
column: x => x.ReturnLineId,
principalTable: "sales_return_lines",
principalColumn: "ReturnLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_return_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_slip_line_serials",
columns: table => new
{
SalesSlipLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SalesSlipLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_slip_line_serials", x => x.SalesSlipLineSerialId);
table.ForeignKey(
name: "FK_sales_slip_line_serials_sales_slip_lines_SalesSlipLineId",
column: x => x.SalesSlipLineId,
principalTable: "sales_slip_lines",
principalColumn: "SalesSlipLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_slip_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "serial_movements",
columns: table => new
{
SerialMovementId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SerialId = table.Column<int>(type: "int", nullable: false),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
FromWarehouseId = table.Column<int>(type: "int", nullable: true),
WarehouseId = table.Column<int>(type: "int", nullable: true),
UserId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_serial_movements", x => x.SerialMovementId);
table.ForeignKey(
name: "FK_serial_movements_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "stock_transfer_line_serials",
columns: table => new
{
StockTransferLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TransferLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false),
BatchId = table.Column<int>(type: "int", nullable: true),
UnitCost = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true),
ReceivedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_transfer_line_serials", x => x.StockTransferLineSerialId);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_stock_transfer_lines_TransferLin~",
column: x => x.TransferLineId,
principalTable: "stock_transfer_lines",
principalColumn: "TransferLineId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "warranty_registrations",
columns: table => new
{
WarrantyRegistrationId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ItemId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: true),
CustomerId = table.Column<int>(type: "int", nullable: false),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
SourceLineId = table.Column<int>(type: "int", nullable: false),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
PeriodMonths = table.Column<int>(type: "int", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
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_warranty_registrations", x => x.WarrantyRegistrationId);
table.ForeignKey(
name: "FK_warranty_registrations_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_warranty_registrations_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_warranty_registrations_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_serials_BinId",
table: "serials",
column: "BinId");
migrationBuilder.CreateIndex(
name: "IX_serials_GrnLineId",
table: "serials",
column: "GrnLineId");
migrationBuilder.CreateIndex(
name: "IX_serials_ItemId_Status",
table: "serials",
columns: new[] { "ItemId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_serials_WarehouseId_ItemId_Status",
table: "serials",
columns: new[] { "WarehouseId", "ItemId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_BatchId",
table: "sales_return_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_lines_BatchId",
table: "sales_invoice_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_grn_line_serials_GrnLineId_SerialNo",
table: "grn_line_serials",
columns: new[] { "GrnLineId", "SerialNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_grn_line_serials_SerialId",
table: "grn_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_line_serials_SalesInvoiceLineId_SerialId",
table: "sales_invoice_line_serials",
columns: new[] { "SalesInvoiceLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_line_serials_SerialId",
table: "sales_invoice_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_BatchId",
table: "sales_line_allocations",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_SerialId",
table: "sales_line_allocations",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_SourceDocType_SourceDocId_SourceLineId",
table: "sales_line_allocations",
columns: new[] { "SourceDocType", "SourceDocId", "SourceLineId" });
migrationBuilder.CreateIndex(
name: "IX_sales_return_line_serials_ReturnLineId_SerialId",
table: "sales_return_line_serials",
columns: new[] { "ReturnLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_return_line_serials_SerialId",
table: "sales_return_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_slip_line_serials_SalesSlipLineId_SerialId",
table: "sales_slip_line_serials",
columns: new[] { "SalesSlipLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_slip_line_serials_SerialId",
table: "sales_slip_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_serial_movements_SerialId_SerialMovementId",
table: "serial_movements",
columns: new[] { "SerialId", "SerialMovementId" });
migrationBuilder.CreateIndex(
name: "IX_serial_movements_SourceDocType_SourceDocId",
table: "serial_movements",
columns: new[] { "SourceDocType", "SourceDocId" });
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_BatchId",
table: "stock_transfer_line_serials",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_SerialId",
table: "stock_transfer_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_TransferLineId_SerialId",
table: "stock_transfer_line_serials",
columns: new[] { "TransferLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_CustomerId",
table: "warranty_registrations",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_ItemId",
table: "warranty_registrations",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_SerialId",
table: "warranty_registrations",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_SourceDocType_SourceDocId",
table: "warranty_registrations",
columns: new[] { "SourceDocType", "SourceDocId" });
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_Status_EndDate",
table: "warranty_registrations",
columns: new[] { "Status", "EndDate" });
migrationBuilder.AddForeignKey(
name: "FK_sales_invoice_lines_batches_BatchId",
table: "sales_invoice_lines",
column: "BatchId",
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_sales_return_lines_batches_BatchId",
table: "sales_return_lines",
column: "BatchId",
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_bins_BinId",
table: "serials",
column: "BinId",
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_grn_lines_GrnLineId",
table: "serials",
column: "GrnLineId",
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_warehouses_WarehouseId",
table: "serials",
column: "WarehouseId",
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
// Backfill serial rows that predate this migration: a real CreatedAt, a live
// concurrency token (0 is never live — see ErpDbContext.BumpRowVersions), and the
// warehouse of an in-stock serial's own open layer, if it has one.
migrationBuilder.Sql("""
UPDATE `serials` SET `CreatedAt` = UTC_TIMESTAMP(6) WHERE `CreatedAt` < '1900-01-01';
UPDATE `serials` SET `RowVersion` = 1 WHERE `RowVersion` = 0;
UPDATE `serials` s
JOIN `stock_layers` l ON l.`SerialId` = s.`SerialId` AND l.`QtyRemaining` > 0
SET s.`WarehouseId` = l.`WarehouseId`
WHERE s.`Status` = 'InStock' AND s.`WarehouseId` IS NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_invoice_lines_batches_BatchId",
table: "sales_invoice_lines");
migrationBuilder.DropForeignKey(
name: "FK_sales_return_lines_batches_BatchId",
table: "sales_return_lines");
migrationBuilder.DropForeignKey(
name: "FK_serials_bins_BinId",
table: "serials");
migrationBuilder.DropForeignKey(
name: "FK_serials_grn_lines_GrnLineId",
table: "serials");
migrationBuilder.DropForeignKey(
name: "FK_serials_warehouses_WarehouseId",
table: "serials");
migrationBuilder.DropTable(
name: "grn_line_serials");
migrationBuilder.DropTable(
name: "sales_invoice_line_serials");
migrationBuilder.DropTable(
name: "sales_line_allocations");
migrationBuilder.DropTable(
name: "sales_return_line_serials");
migrationBuilder.DropTable(
name: "sales_slip_line_serials");
migrationBuilder.DropTable(
name: "serial_movements");
migrationBuilder.DropTable(
name: "stock_transfer_line_serials");
migrationBuilder.DropTable(
name: "warranty_registrations");
migrationBuilder.DropIndex(
name: "IX_serials_BinId",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_GrnLineId",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_ItemId_Status",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_WarehouseId_ItemId_Status",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_sales_return_lines_BatchId",
table: "sales_return_lines");
migrationBuilder.DropIndex(
name: "IX_sales_invoice_lines_BatchId",
table: "sales_invoice_lines");
migrationBuilder.DropColumn(
name: "BinId",
table: "serials");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "GrnLineId",
table: "serials");
migrationBuilder.DropColumn(
name: "ReceivedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "serials");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "WarehouseId",
table: "serials");
migrationBuilder.DropColumn(
name: "WarrantyPeriodMonths",
table: "serials");
migrationBuilder.DropColumn(
name: "BatchId",
table: "sales_return_lines");
migrationBuilder.DropColumn(
name: "BatchId",
table: "sales_invoice_lines");
}
}
}
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

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