Merge pull request 'Dev' (#109) from Dev into production
Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using ERPCore.Infra.Gl;
|
using ERPCore.Infra.Gl;
|
||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
|
||||||
using ERPCore.System.Time;
|
using ERPCore.System.Time;
|
||||||
|
using ERPCore.Tests.TestSupport;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
namespace ERPCore.Tests;
|
namespace ERPCore.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
|
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
|
||||||
/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
|
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
|
||||||
/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
|
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
|
||||||
|
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GlAccountCodeValidatorTests
|
public class GlAccountCodeValidatorTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
|
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
new(),
|
||||||
new StubGl(["1100", "2000", "4000"]));
|
new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1100"),
|
||||||
|
StubGeneralLedgerService.Postable("2000")
|
||||||
|
]),
|
||||||
|
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The default must not block startup. Throwing here cost an outage on 2026-10-07 — a config
|
|
||||||
/// mismatch that had existed all along became a core-dump the moment the check shipped.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault()
|
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new()
|
new(),
|
||||||
{
|
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
|
||||||
["Grn:GlInventoryAccountCode"] = "1100",
|
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
|
||||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
|
||||||
["Adjustment:GlLossAccountCode"] = "5100"
|
|
||||||
},
|
|
||||||
new StubGl(["1000-01"]));
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
|
|||||||
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
|
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new()
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
{
|
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
|
||||||
["GeneralLedgerService:FailOnMissingAccountCodes"] = "true",
|
(GlAccountRole.Inventory, "1100"),
|
||||||
["Grn:GlInventoryAccountCode"] = "1100",
|
(GlAccountRole.Cash, "1000-01"),
|
||||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
(GlAccountRole.InventoryLoss, "5100"));
|
||||||
["Adjustment:GlLossAccountCode"] = "5100"
|
|
||||||
},
|
|
||||||
new StubGl(["1100"]));
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => validator.StartAsync(CancellationToken.None));
|
() => validator.StartAsync(CancellationToken.None));
|
||||||
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
|
|||||||
Assert.Contains("1000-01", ex.Message);
|
Assert.Contains("1000-01", ex.Message);
|
||||||
Assert.Contains("5100", ex.Message);
|
Assert.Contains("5100", ex.Message);
|
||||||
Assert.Contains("2 of 3", ex.Message);
|
Assert.Contains("2 of 3", ex.Message);
|
||||||
Assert.DoesNotContain("1100,", ex.Message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
|
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
|
||||||
/// validator would call every configured code missing and refuse to start.
|
/// "everything is missing" would refuse to start on a contract change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
|
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
new StubGl([]));
|
new StubGeneralLedgerService([]),
|
||||||
|
(GlAccountRole.Inventory, "1100"));
|
||||||
await validator.StartAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
|
|
||||||
{
|
|
||||||
var validator = CreateValidator(
|
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
|
||||||
new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The earlier catch filter only matched DomainException/OperationCanceledException, so anything
|
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
|
||||||
/// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host.
|
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
|
||||||
/// A diagnostic must not be able to crash the thing it is diagnosing.
|
/// mapping — escaped a hosted service and aborted the host.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Theory]
|
[Theory]
|
||||||
[MemberData(nameof(UnexpectedFailures))]
|
[MemberData(nameof(UnexpectedFailures))]
|
||||||
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
|
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var validator = CreateValidator(
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
new StubGl(failure));
|
new StubGeneralLedgerService(failure),
|
||||||
|
(GlAccountRole.Inventory, "1100"));
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TheoryData<Exception> UnexpectedFailures() =>
|
public static TheoryData<Exception> UnexpectedFailures() =>
|
||||||
[
|
[
|
||||||
|
StubGeneralLedgerService.Unreachable(),
|
||||||
new NullReferenceException("items was null"),
|
new NullReferenceException("items was null"),
|
||||||
new global::System.Text.Json.JsonException("unexpected shape"),
|
new global::System.Text.Json.JsonException("unexpected shape"),
|
||||||
new InvalidOperationException("something else entirely"),
|
new InvalidOperationException("something else entirely"),
|
||||||
new HttpRequestException("connection reset"),
|
new HttpRequestException("connection reset"),
|
||||||
|
new OperationCanceledException(),
|
||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
|
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
|
||||||
{
|
{
|
||||||
var validator = CreateValidator(
|
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
|
||||||
new StubGl(new OperationCanceledException()));
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
|
|
||||||
{
|
|
||||||
var gl = new StubGl(["1100"]);
|
|
||||||
var validator = CreateValidator(
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
["Grn:GlInventoryAccountCode"] = "1100",
|
|
||||||
["Grn:GlPostingEnabled"] = "true",
|
|
||||||
["Grn:SomeOtherCode"] = "not-an-account"
|
|
||||||
},
|
|
||||||
gl);
|
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(1, gl.Calls);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
|
|
||||||
{
|
|
||||||
var gl = new StubGl(["1100"]);
|
|
||||||
var validator = CreateValidator(new(), gl);
|
var validator = CreateValidator(new(), gl);
|
||||||
|
|
||||||
await validator.StartAsync(CancellationToken.None);
|
await validator.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(0, gl.Calls);
|
Assert.Equal(0, gl.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
|
||||||
|
{
|
||||||
|
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||||
|
|
||||||
|
var validator = CreateValidator(
|
||||||
|
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||||
|
gl,
|
||||||
|
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
|
||||||
|
|
||||||
|
await validator.StartAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(1, gl.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
||||||
@@ -165,39 +138,14 @@ public class GlAccountCodeValidatorTests
|
|||||||
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
|
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GlAccountCodeValidator CreateValidator(Dictionary<string, string?> settings, IGeneralLedgerService gl)
|
private static GlAccountCodeValidator CreateValidator(
|
||||||
|
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
|
||||||
{
|
{
|
||||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||||
var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
|
var services = new ServiceCollection()
|
||||||
|
.AddScoped<IGeneralLedgerService>(_ => gl)
|
||||||
|
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
|
||||||
|
.BuildServiceProvider();
|
||||||
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
|
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubGl(HashSet<string>? codes = null, Exception? throws = null) : IGeneralLedgerService
|
|
||||||
{
|
|
||||||
private readonly HashSet<string> _codes = codes ?? [];
|
|
||||||
private readonly Exception? _throws = throws;
|
|
||||||
|
|
||||||
public StubGl(Exception throws) : this(null, throws) { }
|
|
||||||
|
|
||||||
public int Calls { get; private set; }
|
|
||||||
|
|
||||||
public Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
Calls++;
|
|
||||||
return _throws is not null ? Task.FromException<HashSet<string>>(_throws) : Task.FromResult(_codes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<ERPCore.Infra.Gl.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)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
|
|
||||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
|
|
||||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using ERPCore.Repositories;
|
||||||
|
using ERPCore.Services;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.Tests.TestSupport;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The point of this service is that a mapping GL cannot post to never reaches the database.
|
||||||
|
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
|
||||||
|
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
|
||||||
|
/// existed in GL at all.
|
||||||
|
/// </summary>
|
||||||
|
public class GlAccountConfigServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var gl = new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||||
|
StubGeneralLedgerService.Postable("2000", "GRNI")
|
||||||
|
]);
|
||||||
|
var service = CreateService(db, gl);
|
||||||
|
var before = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var result = await service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
|
||||||
|
|
||||||
|
Assert.True(result.RowVersion > before);
|
||||||
|
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||||
|
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
|
||||||
|
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||||
|
Assert.Equal(422, ex.StatusCode);
|
||||||
|
Assert.Contains("9999", ex.Message);
|
||||||
|
Assert.Contains("Clearing", ex.Message);
|
||||||
|
await AssertNothingStoredAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GL rejects a journal line against a non-postable account, so storing one only defers the
|
||||||
|
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RejectsANonPostableAccount()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
|
||||||
|
]));
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||||
|
Assert.Contains("not postable", ex.Message);
|
||||||
|
await AssertNothingStoredAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
|
||||||
|
await AssertNothingStoredAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||||
|
Assert.Equal(503, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RejectsAStaleIfMatch()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||||
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||||
|
Assert.Equal(412, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_TreatsBlankAsCleared()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||||
|
var service = CreateService(db, gl);
|
||||||
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||||
|
|
||||||
|
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
|
||||||
|
|
||||||
|
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||||
|
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||||
|
|
||||||
|
var codes = await service.GetCodesAsync();
|
||||||
|
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
|
||||||
|
Assert.Contains("Cogs", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCodesAsync_ReadsOncePerScope()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||||
|
|
||||||
|
var first = await service.GetCodesAsync();
|
||||||
|
var second = await service.GetCodesAsync();
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||||
|
|
||||||
|
var result = await service.GetAsync();
|
||||||
|
|
||||||
|
Assert.True(result.Value.GlUnavailable);
|
||||||
|
Assert.Empty(result.Value.Options);
|
||||||
|
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
|
||||||
|
Assert.Equal("1100", inventory.AccountCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync(c =>
|
||||||
|
{
|
||||||
|
c.InventoryAccountCode = "1100";
|
||||||
|
c.CashAccountCode = "1000-01";
|
||||||
|
});
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
|
||||||
|
]));
|
||||||
|
|
||||||
|
var result = await service.GetAsync();
|
||||||
|
|
||||||
|
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
|
||||||
|
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The picker must only offer what validation will accept.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
|
||||||
|
{
|
||||||
|
await using var db = await CreateSeededContextAsync();
|
||||||
|
var service = CreateService(db, new StubGeneralLedgerService([
|
||||||
|
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||||
|
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
|
||||||
|
new GlAccount("1200", "Retired", 1, true, false)
|
||||||
|
]));
|
||||||
|
|
||||||
|
var options = (await service.GetAsync()).Value.Options;
|
||||||
|
|
||||||
|
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AssertNothingStoredAsync(ErpDbContext db)
|
||||||
|
{
|
||||||
|
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
|
||||||
|
Assert.Null(row!.InventoryAccountCode);
|
||||||
|
Assert.Null(row.ClearingAccountCode);
|
||||||
|
Assert.Null(row.CashAccountCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
|
||||||
|
{
|
||||||
|
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
|
||||||
|
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
|
||||||
|
|
||||||
|
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
|
||||||
|
configure?.Invoke(config);
|
||||||
|
db.GlAccountConfig.Add(config);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
|
||||||
|
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
|
||||||
|
}
|
||||||
@@ -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,66 @@
|
|||||||
|
using ERPCore.Infra.Gl;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
|
||||||
|
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
|
||||||
|
/// doing so fails loudly rather than against a silently empty fake.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<GlAccount> _accounts;
|
||||||
|
private readonly Exception? _throws;
|
||||||
|
|
||||||
|
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
|
||||||
|
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
|
||||||
|
|
||||||
|
public int Calls { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>A postable, active account — the ordinary case.</summary>
|
||||||
|
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
|
||||||
|
|
||||||
|
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
|
||||||
|
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return _throws is not null
|
||||||
|
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
|
||||||
|
: Task.FromResult(_accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public static DomainException Unreachable() =>
|
||||||
|
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
|
||||||
|
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
|
||||||
|
{
|
||||||
|
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
|
||||||
|
|
||||||
|
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
|
||||||
|
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Role → GL account mapping endpoints.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
|
||||||
|
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
|
||||||
|
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
|
||||||
|
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
|
||||||
|
/// and the server-side validation work from the same list.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/gl-account-config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
|
public sealed class GlAccountConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGlAccountConfigService _config;
|
||||||
|
|
||||||
|
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
|
||||||
|
/// not be reached, since a mapping that could not be verified is not stored.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
|
public async Task<ActionResult<GlAccountConfigDto>> Update(
|
||||||
|
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
/// <summary>One selectable GL account, for the mapping pickers.</summary>
|
||||||
|
public sealed record GlAccountOptionDto(string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One role's current mapping. <see cref="ExistsInGl"/> is false when the saved code is not in GL's
|
||||||
|
/// chart of accounts — which is the state 11 of these were found in on 2026-10-07. The screen shows
|
||||||
|
/// those as flagged rather than blank, so the mapping that is actually stored stays visible.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GlAccountMappingDto(
|
||||||
|
string Role, string? AccountCode, string? AccountName, bool ExistsInGl, bool IsPostable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GL account mapping resource. Carries the options alongside the mappings so the picker and the
|
||||||
|
/// server's validation list are the same data — a code the user can choose is a code that will save.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="GlUnavailable"/> is true when GL could not be reached: mappings still render from the
|
||||||
|
/// database, <see cref="Options"/> is empty, and saving will be refused until GL answers.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GlAccountConfigDto(
|
||||||
|
IReadOnlyList<GlAccountMappingDto> Mappings,
|
||||||
|
IReadOnlyList<GlAccountOptionDto> Options,
|
||||||
|
bool GlUnavailable,
|
||||||
|
DateTime? UpdatedAt,
|
||||||
|
int? UpdatedBy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full replacement of the mapping. Every role is sent on every save; null or empty clears a role.
|
||||||
|
/// <c>UpdatedBy</c> is derived from the token, never posted.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateGlAccountConfigRequest
|
||||||
|
{
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? VatRecoverableAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsReceivableCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? ClearingAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? TaxPayableAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsPayableCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesRevenueAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesDiscountAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryGainAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
|
||||||
|
}
|
||||||
@@ -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 & 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);
|
||||||
@@ -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);
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
namespace ERPCore.Infra.Gl;
|
namespace ERPCore.Infra.Gl;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Startup guard for the GL chart of accounts. Every <c>Gl*Code</c> in configuration names an
|
/// Startup guard for the GL chart of accounts. Every account mapped in <c>gl_account_config</c>
|
||||||
/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced
|
/// must exist in GL; without this, an un-seeded account only surfaced as a 404 when some user
|
||||||
/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one
|
/// happened to trigger that one flow, mid-transaction — one flow, one discovery, repeat. This
|
||||||
/// discovery, repeat. This asserts the whole set once, at boot.
|
/// asserts the whole set once, at boot.
|
||||||
|
/// <para>
|
||||||
|
/// Advisory only. Enforcement lives at the point of entry — <c>GlAccountConfigService.UpdateAsync</c>
|
||||||
|
/// refuses to store a code GL does not have or cannot post to — so this is a net for accounts that
|
||||||
|
/// disappeared from GL *after* being mapped.
|
||||||
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning
|
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning
|
||||||
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
|
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
|
||||||
@@ -26,39 +32,41 @@ public sealed class GlAccountCodeValidator(
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<GlAccountCodeValidator> logger) : IHostedService
|
ILogger<GlAccountCodeValidator> logger) : IHostedService
|
||||||
{
|
{
|
||||||
/// <summary>Configuration sections that carry GL account codes — see appsettings.json.</summary>
|
|
||||||
private static readonly string[] Sections =
|
|
||||||
["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"];
|
|
||||||
|
|
||||||
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
|
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
|
||||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
|
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken ct)
|
public async Task StartAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var expected = Sections
|
string[] expected;
|
||||||
.SelectMany(s => configuration.GetSection(s).GetChildren())
|
|
||||||
.Where(c => c.Key.StartsWith("Gl", StringComparison.Ordinal) && c.Key.EndsWith("Code", StringComparison.Ordinal))
|
|
||||||
.Select(c => c.Value)
|
|
||||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
||||||
.Select(v => v!)
|
|
||||||
.Distinct(StringComparer.Ordinal)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (expected.Length == 0)
|
|
||||||
{
|
|
||||||
logger.LogWarning("No GL account codes are configured — skipping chart-of-accounts verification.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
HashSet<string> present;
|
HashSet<string> present;
|
||||||
|
|
||||||
|
// The database read sits inside the catch-all too: a DB hiccup here would otherwise escape
|
||||||
|
// a hosted service and reopen the startup-abort hole this class already caused once.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = services.CreateScope();
|
using var scope = services.CreateScope();
|
||||||
|
var mapping = scope.ServiceProvider.GetRequiredService<IGlAccountConfigService>();
|
||||||
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
|
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
|
||||||
|
|
||||||
|
var codes = await mapping.GetCodesAsync(ct);
|
||||||
|
expected = Enum.GetValues<GlAccountRole>()
|
||||||
|
.Select(codes.GetOrNull)
|
||||||
|
.Where(c => c is not null)
|
||||||
|
.Select(c => c!)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (expected.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"No GL accounts are mapped — skipping chart-of-accounts verification. "
|
||||||
|
+ "Map them under Settings → GL Accounts; posting flows will fail until then.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
timeout.CancelAfter(FetchTimeout);
|
timeout.CancelAfter(FetchTimeout);
|
||||||
present = await gl.ListAccountCodesAsync(timeout.Token);
|
present = (await gl.ListAccountsAsync(timeout.Token)).Select(a => a.AccountCode).ToHashSet(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
|
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
|
||||||
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
|
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
|
||||||
@@ -67,8 +75,7 @@ public sealed class GlAccountCodeValidator(
|
|||||||
catch (Exception e) when (!ct.IsCancellationRequested)
|
catch (Exception e) when (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
logger.LogWarning(e,
|
logger.LogWarning(e,
|
||||||
"Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.",
|
"Could not verify the GL account mapping at startup; it is left unchecked.");
|
||||||
expected.Length);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +85,7 @@ public sealed class GlAccountCodeValidator(
|
|||||||
if (present.Count == 0)
|
if (present.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"GL returned no account codes; {Count} configured account code(s) left unverified. " +
|
"GL returned no account codes; {Count} mapped account code(s) left unverified. " +
|
||||||
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
|
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
|
||||||
expected.Length);
|
expected.Length);
|
||||||
return;
|
return;
|
||||||
@@ -88,10 +95,10 @@ public sealed class GlAccountCodeValidator(
|
|||||||
if (missing.Length > 0)
|
if (missing.Length > 0)
|
||||||
{
|
{
|
||||||
var message =
|
var message =
|
||||||
$"General Ledger is missing {missing.Length} of {expected.Length} configured account code(s): " +
|
$"General Ledger is missing {missing.Length} of {expected.Length} mapped account code(s): " +
|
||||||
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
|
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
|
||||||
"will fail against GL until they are seeded in GL's chart of accounts, or the Gl*Code settings are " +
|
"will fail against GL until the accounts are created in GL, or the mapping is corrected under " +
|
||||||
"corrected to match the codes GL actually uses.";
|
"Settings → GL Accounts.";
|
||||||
|
|
||||||
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
|
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
|
||||||
throw new InvalidOperationException(message);
|
throw new InvalidOperationException(message);
|
||||||
@@ -100,7 +107,7 @@ public sealed class GlAccountCodeValidator(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("GL account codes verified: {Count} present.", expected.Length);
|
logger.LogInformation("GL account mapping verified: {Count} account code(s) present.", expected.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ public static class DataSeeder
|
|||||||
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
||||||
];
|
];
|
||||||
|
|
||||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
public static async Task SeedAsync(
|
||||||
|
ErpDbContext db, IConfiguration? configuration = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Must come first, and must save on its own: every branch-scoped entity has a
|
// Must come first, and must save on its own: every branch-scoped entity has a
|
||||||
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
|
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
|
||||||
@@ -60,6 +61,7 @@ public static class DataSeeder
|
|||||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||||
|
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
|
||||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||||
|
|
||||||
if (dirty) await db.SaveChangesAsync(ct);
|
if (dirty) await db.SaveChangesAsync(ct);
|
||||||
@@ -199,6 +201,49 @@ public static class DataSeeder
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
|
||||||
|
/// values once so an existing deployment keeps the mapping it has been running with.
|
||||||
|
/// <para>
|
||||||
|
/// Those keys are <b>seed-only</b>: nothing reads them at runtime any more. Leaving a runtime
|
||||||
|
/// fallback in place would restore the two-sources-of-truth problem this table exists to end —
|
||||||
|
/// after seeding, <c>gl_account_config</c> is authoritative and is edited from
|
||||||
|
/// Settings → GL Accounts, not by redeploying.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately does not call GL: seeding runs inside the startup block that aborts the app on
|
||||||
|
/// failure (see Program.cs), so it must not depend on another service being up. The codes are
|
||||||
|
/// verified separately — advisory at boot by <c>GlAccountCodeValidator</c>, and enforced on
|
||||||
|
/// every save by <c>GlAccountConfigService.UpdateAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> SeedGlAccountConfigAsync(
|
||||||
|
ErpDbContext db, IConfiguration? configuration, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.GlAccountConfig.AnyAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)) return false;
|
||||||
|
|
||||||
|
string? Legacy(string key) =>
|
||||||
|
string.IsNullOrWhiteSpace(configuration?[key]) ? null : configuration[key]!.Trim();
|
||||||
|
|
||||||
|
db.GlAccountConfig.Add(new GlAccountConfig
|
||||||
|
{
|
||||||
|
ConfigId = GlAccountConfig.SingletonId,
|
||||||
|
InventoryAccountCode = Legacy("Grn:GlInventoryAccountCode"),
|
||||||
|
VatRecoverableAccountCode = Legacy("Grn:GlVatRecoverableAccountCode"),
|
||||||
|
AccountsReceivableCode = Legacy("SalesInvoice:GlAccountsReceivableCode"),
|
||||||
|
ClearingAccountCode = Legacy("Grn:GlClearingAccountCode"),
|
||||||
|
TaxPayableAccountCode = Legacy("SalesDayEnd:GlTaxPayableAccountCode"),
|
||||||
|
AccountsPayableCode = Legacy("PurchaseInvoice:GlAccountsPayableCode"),
|
||||||
|
SalesRevenueAccountCode = Legacy("SalesDayEnd:GlSalesRevenueAccountCode"),
|
||||||
|
SalesDiscountAccountCode = Legacy("SalesDayEnd:GlSalesDiscountAccountCode"),
|
||||||
|
InventoryGainAccountCode = Legacy("Adjustment:GlGainAccountCode"),
|
||||||
|
CogsAccountCode = Legacy("SalesDayEnd:GlCogsAccountCode"),
|
||||||
|
InventoryLossAccountCode = Legacy("Adjustment:GlLossAccountCode"),
|
||||||
|
CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode")
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+9481
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,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGlAccountConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "gl_account_config",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ConfigId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
InventoryAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
VatRecoverableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
AccountsReceivableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
ClearingAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
TaxPayableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
AccountsPayableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
SalesRevenueAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
SalesDiscountAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
InventoryGainAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CogsAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
InventoryLossAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CashAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "int", nullable: true),
|
||||||
|
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_gl_account_config", x => x.ConfigId);
|
||||||
|
table.CheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_gl_account_config_users_UpdatedBy",
|
||||||
|
column: x => x.UpdatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_gl_account_config_UpdatedBy",
|
||||||
|
table: "gl_account_config",
|
||||||
|
column: "UpdatedBy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "gl_account_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10153
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);");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10153
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user