price adjustment and stock adjustments
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,388 @@
|
|||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
|
using ERPCore.Dtos.Notifications;
|
||||||
|
using ERPCore.Dtos.Products;
|
||||||
|
using ERPCore.Dtos.Stock;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using ERPCore.Repositories;
|
||||||
|
using ERPCore.Services;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.Services.Stock;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.Tests.TestSupport;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests;
|
||||||
|
|
||||||
|
/// <summary>Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §13).</summary>
|
||||||
|
public class DualApprovalTests
|
||||||
|
{
|
||||||
|
private static readonly InMemoryDatabaseRoot Root = new();
|
||||||
|
|
||||||
|
// Requester, two approvers, and a clerk who can approve but isn't a manager.
|
||||||
|
private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
|
||||||
|
private const int ApproverRoleId = 2, ClerkRoleId = 3;
|
||||||
|
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1;
|
||||||
|
|
||||||
|
// --- Stock adjustments ------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StockAdjustment_IsARequest_NothingMovesUntilApproved()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync();
|
||||||
|
|
||||||
|
var dto = await h.Stock.CreateAsync(Request(-3));
|
||||||
|
|
||||||
|
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
|
||||||
|
Assert.Equal(2, dto.RequiredApprovals);
|
||||||
|
Assert.Equal(1, dto.PendingLevel);
|
||||||
|
Assert.Empty(h.Mutator.Calls);
|
||||||
|
Assert.Empty(h.Gl.Posted);
|
||||||
|
var pending = Assert.Single(h.Notifications.Targeted, n => n.ModuleKey == "approval.pending");
|
||||||
|
Assert.Equal([ApproverA, ApproverB, Clerk], pending.UserIds.Order()); // never the requester
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StockAdjustment_TwoDifferentApprovers_ThenItPosts()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync();
|
||||||
|
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
|
||||||
|
|
||||||
|
h.User.Be(Requester);
|
||||||
|
var own = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||||
|
Assert.Equal(ErrorCodes.ApprovalForbidden, own.Code);
|
||||||
|
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
var first = await h.Stock.ApproveAsync(id, "counted twice");
|
||||||
|
Assert.Equal(AdjustmentStatus.PendingSecondApproval, first.Status);
|
||||||
|
Assert.Empty(h.Mutator.Calls);
|
||||||
|
|
||||||
|
var twice = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||||
|
Assert.Equal(ErrorCodes.ApprovalSamePerson, twice.Code);
|
||||||
|
|
||||||
|
h.User.Be(ApproverB);
|
||||||
|
var posted = await h.Stock.ApproveAsync(id, null);
|
||||||
|
|
||||||
|
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
|
||||||
|
Assert.Equal((ApproverA, ApproverB), (posted.ApprovedLevel1By, posted.ApprovedLevel2By));
|
||||||
|
var call = Assert.Single(h.Mutator.Calls);
|
||||||
|
Assert.Equal(-3m, Assert.Single(call).QtyDelta);
|
||||||
|
Assert.Single(h.Gl.Posted);
|
||||||
|
Assert.NotNull(posted.PostedAt);
|
||||||
|
Assert.Equal([1, 2], posted.ApprovalHistory!.Select(s => s.Level));
|
||||||
|
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.approved" && n.UserIds.SequenceEqual([Requester]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StockAdjustment_Rejected_NeedsAReason_AndNeverPosts()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync();
|
||||||
|
var id = (await h.Stock.CreateAsync(Request(5))).AdjustmentId;
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
|
||||||
|
var noReason = await Assert.ThrowsAsync<DomainException>(() => h.Stock.RejectAsync(id, " "));
|
||||||
|
Assert.Equal(ErrorCodes.ApprovalReasonRequired, noReason.Code);
|
||||||
|
|
||||||
|
var rejected = await h.Stock.RejectAsync(id, "wrong warehouse");
|
||||||
|
Assert.Equal((AdjustmentStatus.Rejected, "wrong warehouse"), (rejected.Status, rejected.RejectReason));
|
||||||
|
|
||||||
|
h.User.Be(ApproverB);
|
||||||
|
var late = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||||
|
Assert.Equal(ErrorCodes.ApprovalNotPending, late.Code);
|
||||||
|
Assert.Empty(h.Mutator.Calls);
|
||||||
|
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StockAdjustment_ApprovalsOff_PostsOnCreate()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync(stockLevels: 0);
|
||||||
|
|
||||||
|
var dto = await h.Stock.CreateAsync(Request(2));
|
||||||
|
|
||||||
|
Assert.Equal((AdjustmentStatus.Posted, 0), (dto.Status, dto.RequiredApprovals));
|
||||||
|
Assert.Single(h.Mutator.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StockAdjustment_BelowTheValueThreshold_NeedsOneApproval()
|
||||||
|
{
|
||||||
|
// 3 units at 10.00 each = 30.00, below the 1,000 threshold.
|
||||||
|
var h = await Harness.CreateAsync(stockThreshold: 1000);
|
||||||
|
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
|
||||||
|
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
var posted = await h.Stock.ApproveAsync(id, null);
|
||||||
|
|
||||||
|
Assert.Equal((AdjustmentStatus.Posted, 1), (posted.Status, posted.RequiredApprovals));
|
||||||
|
Assert.Single(h.Mutator.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StockAdjustment_OnlyTheRequesterCanWithdraw()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync();
|
||||||
|
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
|
||||||
|
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
var other = await Assert.ThrowsAsync<DomainException>(() => h.Stock.CancelAsync(id));
|
||||||
|
Assert.Equal(ErrorCodes.ApprovalForbidden, other.Code);
|
||||||
|
|
||||||
|
h.User.Be(Requester);
|
||||||
|
Assert.Equal(AdjustmentStatus.Cancelled, (await h.Stock.CancelAsync(id)).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SecondApproval_CanBeLimitedToRoles()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync(level2Roles: "Approver");
|
||||||
|
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
await h.Stock.ApproveAsync(id, null);
|
||||||
|
|
||||||
|
h.User.Be(Clerk);
|
||||||
|
var clerk = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||||
|
Assert.Equal(ErrorCodes.ApprovalRoleRequired, clerk.Code);
|
||||||
|
|
||||||
|
h.User.Be(ApproverB);
|
||||||
|
Assert.Equal(AdjustmentStatus.Posted, (await h.Stock.ApproveAsync(id, null)).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Price adjustments ------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PriceAdjustment_AppliesOnlyAfterTheSecondApproval_AndKeepsHistory()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync();
|
||||||
|
var dto = await h.Prices.CreateAsync(PriceRequest(flat: 120m));
|
||||||
|
Assert.Equal(PriceAdjustmentStatus.PendingApproval, dto.Status);
|
||||||
|
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
|
||||||
|
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
|
||||||
|
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
|
||||||
|
|
||||||
|
h.User.Be(ApproverB);
|
||||||
|
var applied = await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
|
||||||
|
|
||||||
|
Assert.Equal(PriceAdjustmentStatus.Approved, applied.Status);
|
||||||
|
Assert.Equal(120m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
|
||||||
|
var history = Assert.Single(await h.Prices.ItemHistoryAsync(ItemId));
|
||||||
|
Assert.Equal((100m, 120m, ApproverA, ApproverB, dto.DocNo), (history.OldFlatPrice, history.NewFlatPrice, history.ApprovedLevel1By, history.ApprovedLevel2By, history.DocNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PriceAdjustment_ItemRepricedSinceTheRequest_IsRefusedAsStale()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync();
|
||||||
|
var id = (await h.Prices.CreateAsync(PriceRequest(flat: 120m))).PriceAdjustmentId;
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
await h.Prices.ApproveAsync(id, null);
|
||||||
|
|
||||||
|
var item = await h.Db.Items.FirstAsync();
|
||||||
|
item.SellingFlatPrice = 105m; // someone changed it in between
|
||||||
|
await h.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
h.User.Be(ApproverB);
|
||||||
|
var stale = await Assert.ThrowsAsync<DomainException>(() => h.Prices.ApproveAsync(id, null));
|
||||||
|
Assert.Equal(ErrorCodes.PriceAdjustmentStale, stale.Code);
|
||||||
|
Assert.Empty(await h.Prices.ItemHistoryAsync(ItemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PriceAdjustment_OnePendingRequestPerItem()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync();
|
||||||
|
await h.Prices.CreateAsync(PriceRequest(flat: 120m));
|
||||||
|
|
||||||
|
var second = await Assert.ThrowsAsync<DomainException>(() => h.Prices.CreateAsync(PriceRequest(flat: 130m)));
|
||||||
|
Assert.Equal(ErrorCodes.PriceAdjustmentItemPending, second.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(100, 120, 20)]
|
||||||
|
[InlineData(100, 80, 20)]
|
||||||
|
[InlineData(-1, 50, 100)] // setting a price where there was none (-1 = no price)
|
||||||
|
[InlineData(50, -1, 100)] // clearing one
|
||||||
|
[InlineData(40, 40, 0)]
|
||||||
|
public void ChangePercent_MeasuresTheSizeOfAPriceChange(int oldPrice, int newPrice, int expected)
|
||||||
|
{
|
||||||
|
decimal? Price(int v) => v < 0 ? null : v;
|
||||||
|
Assert.Equal(expected, PriceAdjustmentService.ChangePercent(Price(oldPrice), Price(newPrice)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2, 0, 5000, 2)] // no threshold: always two
|
||||||
|
[InlineData(2, 1000, 50, 1)] // below threshold: one
|
||||||
|
[InlineData(2, 1000, 1000, 2)] // at threshold: two
|
||||||
|
[InlineData(1, 1000, 5000, 1)]
|
||||||
|
[InlineData(0, 0, 5000, 0)]
|
||||||
|
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
|
||||||
|
=> Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
|
||||||
|
|
||||||
|
// --- helpers ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static CreateAdjustmentRequest Request(decimal qty) => new()
|
||||||
|
{
|
||||||
|
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = ReasonId,
|
||||||
|
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CreatePriceAdjustmentRequest PriceRequest(decimal flat) => new()
|
||||||
|
{
|
||||||
|
Reason = "Supplier price rise",
|
||||||
|
Lines = [new PriceAdjustmentLineInput { ItemId = ItemId, NewMinPrice = 90m, NewMaxPrice = 150m, NewFlatPrice = flat }]
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class Harness
|
||||||
|
{
|
||||||
|
public required ErpDbContext Db { get; init; }
|
||||||
|
public required SwitchableUser User { get; init; }
|
||||||
|
public required RecordingMutator Mutator { get; init; }
|
||||||
|
public required FakeGeneralLedger Gl { get; init; }
|
||||||
|
public required RecordingNotifications Notifications { get; init; }
|
||||||
|
public required AdjustmentService Stock { get; init; }
|
||||||
|
public required PriceAdjustmentService Prices { get; init; }
|
||||||
|
|
||||||
|
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
|
||||||
|
{
|
||||||
|
var db = new ErpDbContext(
|
||||||
|
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
|
||||||
|
new TestCurrentUser(), new CurrentBranchContext(new TestCurrentUser()));
|
||||||
|
|
||||||
|
db.Roles.AddRange(
|
||||||
|
new Role { RoleId = 1, Code = "Requester", Name = "Requester" },
|
||||||
|
new Role { RoleId = ApproverRoleId, Code = "Approver", Name = "Approver" },
|
||||||
|
new Role { RoleId = ClerkRoleId, Code = "Clerk", Name = "Clerk" });
|
||||||
|
db.Users.AddRange(
|
||||||
|
new User { UserId = ERPCore.Domain.Entities.User.SystemUserId, Username = "system", DisplayName = "System" },
|
||||||
|
new User { UserId = Requester, Username = "req", DisplayName = "Requester", RoleId = 1 },
|
||||||
|
new User { UserId = ApproverA, Username = "a", DisplayName = "Approver A", RoleId = ApproverRoleId },
|
||||||
|
new User { UserId = ApproverB, Username = "b", DisplayName = "Approver B", RoleId = ApproverRoleId },
|
||||||
|
new User { UserId = Clerk, Username = "c", DisplayName = "Clerk", RoleId = ClerkRoleId });
|
||||||
|
db.Permissions.AddRange(
|
||||||
|
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments" },
|
||||||
|
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments" });
|
||||||
|
foreach (var roleId in new[] { ApproverRoleId, ClerkRoleId })
|
||||||
|
foreach (var permissionId in new[] { 45, 58 })
|
||||||
|
db.RolePermissions.Add(new RolePermission { RoleId = roleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
|
||||||
|
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 45, Actions = RolePermissionAction.View | RolePermissionAction.Create });
|
||||||
|
|
||||||
|
db.Branches.Add(new Branch { BranchId = 1, Code = "BR-001", Name = "Main" });
|
||||||
|
db.Warehouses.Add(new Warehouse { WarehouseId = WarehouseId, Code = "WH-1", Name = "Main Store", BranchId = 1 });
|
||||||
|
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
|
||||||
|
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
|
||||||
|
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
|
||||||
|
db.ReasonCodes.Add(new ReasonCode { ReasonCodeId = ReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
|
||||||
|
db.ApprovalConfig.Add(new ApprovalConfig
|
||||||
|
{
|
||||||
|
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
|
||||||
|
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var user = new SwitchableUser(Requester, "Requester");
|
||||||
|
var uow = new PassThroughUnitOfWork(db);
|
||||||
|
var notifications = new RecordingNotifications();
|
||||||
|
var approvals = new ApprovalService(
|
||||||
|
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
|
||||||
|
new Repository<RolePermission>(db), user, notifications, uow);
|
||||||
|
var gl = new FakeGeneralLedger();
|
||||||
|
var mutator = new RecordingMutator();
|
||||||
|
var numbers = new FakeNumberSequence();
|
||||||
|
|
||||||
|
var stock = new AdjustmentService(
|
||||||
|
new Repository<StockAdjustment>(db), new Repository<Warehouse>(db), new Repository<Item>(db),
|
||||||
|
new Repository<ReasonCode>(db), new Repository<StockLedger>(db), mutator, new FixedValuation(),
|
||||||
|
numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
|
||||||
|
new ConfigurationBuilder().Build(),
|
||||||
|
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
||||||
|
new Repository<ProductConfig>(db), user));
|
||||||
|
var prices = new PriceAdjustmentService(
|
||||||
|
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db),
|
||||||
|
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow);
|
||||||
|
|
||||||
|
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A signed-in user whose identity the test switches between steps.</summary>
|
||||||
|
private sealed class SwitchableUser(int userId, string roleCode) : ICurrentUser
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<int, string> Roles = new()
|
||||||
|
{
|
||||||
|
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
|
||||||
|
};
|
||||||
|
|
||||||
|
public int AuditUserId { get; private set; } = userId;
|
||||||
|
public string? RoleCode { get; private set; } = roleCode;
|
||||||
|
public string UserId => AuditUserId.ToString();
|
||||||
|
public bool IsAuthenticated => true;
|
||||||
|
public bool IsAdmin => false;
|
||||||
|
public int? BranchId => null;
|
||||||
|
|
||||||
|
public void Be(int userId)
|
||||||
|
{
|
||||||
|
AuditUserId = userId;
|
||||||
|
RoleCode = Roles[userId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records what would be posted to stock; every unit is worth 10.00.</summary>
|
||||||
|
private sealed class RecordingMutator : IStockMutator
|
||||||
|
{
|
||||||
|
public List<IReadOnlyList<StockDelta>> Calls { get; } = new();
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<StockLedger>> ApplyAsync(int warehouseId, string sourceDocType, int sourceDocId, DateTime now,
|
||||||
|
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Calls.Add(deltas);
|
||||||
|
IReadOnlyList<StockLedger> refs = deltas.Select(d => new StockLedger
|
||||||
|
{
|
||||||
|
ItemId = d.ItemId, WarehouseId = warehouseId, QtyBase = Math.Abs(d.QtyDelta), UnitCost = 10m, Value = Math.Abs(d.QtyDelta) * 10m,
|
||||||
|
Direction = d.QtyDelta < 0 ? Direction.Out : Direction.In, SourceDocType = sourceDocType, SourceDocId = sourceDocId, CreatedAt = now
|
||||||
|
}).ToList();
|
||||||
|
return Task.FromResult(refs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>10 units on hand worth 100.00 — 10.00 each, which prices requests for the threshold.</summary>
|
||||||
|
private sealed class FixedValuation : IFifoCostingService
|
||||||
|
{
|
||||||
|
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new StockValuationDto(itemId, warehouseId, [], 10m, 100m, "LKR", "FIFO"));
|
||||||
|
public Task<StockLayer> CreateInboundLayerAsync(int itemId, int warehouseId, int? batchId, int? serialId, int? grnLineId,
|
||||||
|
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||||
|
bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null) => throw new NotSupportedException();
|
||||||
|
public Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||||
|
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
|
||||||
|
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException();
|
||||||
|
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingNotifications : INotificationService
|
||||||
|
{
|
||||||
|
public List<(IReadOnlyList<int> UserIds, string ModuleKey, string Message)> Targeted { get; } = new();
|
||||||
|
|
||||||
|
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null,
|
||||||
|
CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message,
|
||||||
|
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Targeted.Add((userIds.ToList(), moduleKey, message));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Notifications;
|
||||||
|
using ERPCore.Dtos.Stock;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using ERPCore.Repositories;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.Services.Stock;
|
||||||
|
using ERPCore.Tests.TestSupport;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests;
|
||||||
|
|
||||||
|
/// <summary>Low-stock / reorder alerts raised after stock issues.</summary>
|
||||||
|
public class LowStockAlertServiceTests
|
||||||
|
{
|
||||||
|
private const int ItemId = 1, WarehouseId = 1;
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(12, 4, 8, true)] // 12 → 8 crosses the alert level of 10
|
||||||
|
[InlineData(12, 2, 10, true)] // landing exactly on the level counts
|
||||||
|
[InlineData(20, 5, 15, false)] // still above the level
|
||||||
|
[InlineData(9, 3, 6, false)] // was already low — no repeat alert on every sale
|
||||||
|
public async Task Alerts_OnlyWhenAnIssueCrossesTheReorderPoint(decimal before, decimal issued, decimal after, bool alerts)
|
||||||
|
{
|
||||||
|
Assert.Equal(before - issued, after);
|
||||||
|
await using var db = Seed();
|
||||||
|
var tracker = new LowStockTracker();
|
||||||
|
tracker.Track(ItemId, WarehouseId, issued);
|
||||||
|
var notifications = new RecordingNotifications();
|
||||||
|
|
||||||
|
await Service(db, tracker, after, notifications).CheckAsync();
|
||||||
|
|
||||||
|
if (!alerts)
|
||||||
|
{
|
||||||
|
Assert.Empty(notifications.Raised);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var raised = Assert.Single(notifications.Raised);
|
||||||
|
Assert.Equal(LowStockAlertService.ModuleKey, raised.ModuleKey);
|
||||||
|
Assert.Contains("SKU-1", raised.Message);
|
||||||
|
Assert.Contains($"down to {after:0.####} pcs at WH-1", raised.Message);
|
||||||
|
Assert.Contains("Suggested reorder: 50 pcs", raised.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ItemsWithoutARule_OrUntouched_AreIgnored()
|
||||||
|
{
|
||||||
|
await using var db = Seed();
|
||||||
|
var tracker = new LowStockTracker();
|
||||||
|
tracker.Track(itemId: 99, WarehouseId, 100); // no rule for this item
|
||||||
|
var notifications = new RecordingNotifications();
|
||||||
|
|
||||||
|
await Service(db, tracker, available: 0, notifications).CheckAsync();
|
||||||
|
await Service(db, new LowStockTracker(), available: 0, notifications).CheckAsync(); // nothing issued
|
||||||
|
|
||||||
|
Assert.Empty(notifications.Raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tracker_SumsIssuesWithinARequest_AndEmptiesWhenDrained()
|
||||||
|
{
|
||||||
|
var tracker = new LowStockTracker();
|
||||||
|
tracker.Track(ItemId, WarehouseId, 3);
|
||||||
|
tracker.Track(ItemId, WarehouseId, 4);
|
||||||
|
tracker.Track(ItemId, WarehouseId, 0);
|
||||||
|
|
||||||
|
Assert.Equal(7m, tracker.Drain()[(ItemId, WarehouseId)]);
|
||||||
|
Assert.Empty(tracker.Drain());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LowStockAlertService Service(ErpDbContext db, LowStockTracker tracker, decimal available, INotificationService notifications)
|
||||||
|
=> new(tracker, new Repository<ItemReorder>(db), new FixedStock(available), notifications);
|
||||||
|
|
||||||
|
private static ErpDbContext Seed()
|
||||||
|
{
|
||||||
|
var db = BankDepositTestSupport.CreateContext(); // users, branches, warehouses WH-1 / WH-2
|
||||||
|
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
|
||||||
|
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola 500ml", BaseUomId = 1, CategoryId = 1 });
|
||||||
|
db.ItemReorders.Add(new ItemReorder { ReorderId = 1, ItemId = ItemId, WarehouseId = WarehouseId, ReorderPoint = 10, ReorderQty = 50 });
|
||||||
|
db.SaveChanges();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedStock(decimal available) : IStockService
|
||||||
|
{
|
||||||
|
public Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new StockOnHandDto(itemId, warehouseId, available, available, 0, 0, 0, DateTime.UtcNow));
|
||||||
|
public Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||||
|
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingNotifications : INotificationService
|
||||||
|
{
|
||||||
|
public List<(string ModuleKey, string Title, string Message)> Raised { get; } = new();
|
||||||
|
|
||||||
|
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null,
|
||||||
|
int? relatedEntityId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Raised.Add((moduleKey, title, message));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message,
|
||||||
|
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.Gl;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories;
|
||||||
|
using ERPCore.Services;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>Records every journal it is asked to post; can be told to reject them instead.</summary>
|
||||||
|
internal sealed class FakeGeneralLedger : IGeneralLedgerService
|
||||||
|
{
|
||||||
|
public const long CashAccountId = 1, BankAccountId = 2, OtherBankAccountId = 3;
|
||||||
|
|
||||||
|
public List<GlJournalEntryRequest> Posted { get; } = new();
|
||||||
|
public bool Reject { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<GlBankAccount> Accounts { get; init; } =
|
||||||
|
[
|
||||||
|
new("Cash", CashAccountId, "Main Till", null, "Petty", null, 101, "1000-01", "LKR"),
|
||||||
|
new("Bank", BankAccountId, "BOC Current", "Bank of Ceylon", null, "0001234567", 102, "1010-01", "LKR"),
|
||||||
|
new("Bank", OtherBankAccountId, "HNB Savings", "Hatton National Bank", null, "9876543210", 103, "1010-02", "LKR")
|
||||||
|
];
|
||||||
|
|
||||||
|
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (Reject) throw new DomainException(ErrorCodes.Validation, "GL rejected the entry.", 502);
|
||||||
|
Posted.Add(request);
|
||||||
|
return Task.FromResult(new GlJournalEntryResult($"JE-{Posted.Count}", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The in-memory provider has no transactions: run the work, then save — what the real one commits.</summary>
|
||||||
|
internal sealed class PassThroughUnitOfWork(ErpDbContext db) : IUnitOfWork
|
||||||
|
{
|
||||||
|
public Task<int> SaveChangesAsync(CancellationToken ct = default) => db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await action(ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await action(ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeNumberSequence : INumberSequenceService
|
||||||
|
{
|
||||||
|
private int _next;
|
||||||
|
public Task<string> NextAsync(string docType, int branchId, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
|
||||||
|
public Task<string> NextAsync(string docType, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Branching on: the requested branch is used as-is.</summary>
|
||||||
|
internal sealed class FakeBranchConfig : IBranchConfigService
|
||||||
|
{
|
||||||
|
public Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<ETagged<BranchConfigDto>> UpdateAsync(UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
public Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||||
|
public Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
|
||||||
|
=> requestedBranchId > 0 ? Task.FromResult(requestedBranchId) : throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
|
||||||
|
public Task<bool> IsWarehouseBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||||
|
public Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default) => Task.FromResult(requestedBranchId);
|
||||||
|
public Task<int> ResolveDefaultBranchIdAsync(CancellationToken ct = default) => Task.FromResult(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds and services for the cash-in-hand / bank-deposit tests. Two branches, one warehouse each.</summary>
|
||||||
|
internal static class BankDepositTestSupport
|
||||||
|
{
|
||||||
|
private static readonly InMemoryDatabaseRoot Root = new();
|
||||||
|
|
||||||
|
public const int Branch1 = 1, Branch2 = 2;
|
||||||
|
public const int Warehouse1 = 1, Warehouse2 = 2;
|
||||||
|
|
||||||
|
public static BusinessDayClock Clock { get; } = new(Config());
|
||||||
|
|
||||||
|
public static IConfiguration Config(DateOnly? trackingStart = null) => new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["DayEndReport:TimeZone"] = "Asia/Colombo",
|
||||||
|
["BankDeposit:DefaultCashAccountCode"] = "1000-01",
|
||||||
|
["BankDeposit:CashTrackingStartDate"] = trackingStart?.ToString("yyyy-MM-dd")
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
/// <summary>Midday on a local business day, as the UTC instant documents are stamped with.</summary>
|
||||||
|
public static DateTime At(DateOnly day) => Clock.ToUtc(day).AddHours(6);
|
||||||
|
|
||||||
|
public static ErpDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
|
||||||
|
.Options;
|
||||||
|
var currentUser = new TestCurrentUser();
|
||||||
|
var db = new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||||
|
|
||||||
|
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
|
||||||
|
db.Branches.AddRange(
|
||||||
|
new Branch { BranchId = Branch1, Code = "BR-001", Name = "Colombo" },
|
||||||
|
new Branch { BranchId = Branch2, Code = "BR-002", Name = "Kandy" });
|
||||||
|
db.Warehouses.AddRange(
|
||||||
|
new Warehouse { WarehouseId = Warehouse1, Code = "WH-1", Name = "Colombo Store", BranchId = Branch1 },
|
||||||
|
new Warehouse { WarehouseId = Warehouse2, Code = "WH-2", Name = "Kandy Store", BranchId = Branch2 });
|
||||||
|
db.SaveChanges();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int _id;
|
||||||
|
|
||||||
|
public static SalesSlip CashSlip(DateOnly day, decimal cash, decimal card = 0m, int warehouseId = Warehouse1)
|
||||||
|
{
|
||||||
|
var id = Interlocked.Increment(ref _id);
|
||||||
|
return new SalesSlip
|
||||||
|
{
|
||||||
|
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = At(day), Status = SalesSlipStatus.Posted, WarehouseId = warehouseId,
|
||||||
|
GrandTotal = cash + card, PaidAmount = cash + card, CashAmount = cash, CardAmount = card
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
|
||||||
|
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
|
||||||
|
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db),
|
||||||
|
new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
|
||||||
|
|
||||||
|
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
|
||||||
|
{
|
||||||
|
var currentUser = new TestCurrentUser();
|
||||||
|
return new BankDepositService(
|
||||||
|
new Repository<BankDeposit>(db), new Repository<Branch>(db), CashService(db, trackingStart), gl,
|
||||||
|
currentUser, new CurrentBranchContext(currentUser), new FakeNumberSequence(), new FakeBranchConfig(),
|
||||||
|
new PassThroughUnitOfWork(db), db, Clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BankDeposit Deposit(DateOnly businessDate, decimal amount, int branchId = Branch1,
|
||||||
|
BankDepositStatus status = BankDepositStatus.Posted)
|
||||||
|
{
|
||||||
|
var id = Interlocked.Increment(ref _id);
|
||||||
|
return new BankDeposit
|
||||||
|
{
|
||||||
|
BankDepositId = id, DocNo = $"BD-{id}", BranchId = branchId, BusinessDate = businessDate, DepositDate = businessDate,
|
||||||
|
Amount = amount, Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
|
||||||
|
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current", CreatedBy = User.SystemUserId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10). The inbox is open to every
|
||||||
|
/// signed-in user — it only lists what they may approve and their own requests. Reading the
|
||||||
|
/// settings is open too (screens need to know whether prices are locked); changing them needs
|
||||||
|
/// <c>settings.approvals</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/approvals")]
|
||||||
|
public sealed class ApprovalsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ApprovalInboxService _inbox;
|
||||||
|
private readonly IApprovalService _approvals;
|
||||||
|
|
||||||
|
public ApprovalsController(ApprovalInboxService inbox, IApprovalService approvals)
|
||||||
|
{
|
||||||
|
_inbox = inbox;
|
||||||
|
_approvals = approvals;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("pending")]
|
||||||
|
[ProducesResponseType(typeof(PendingApprovalsDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PendingApprovalsDto>> Pending(CancellationToken ct)
|
||||||
|
=> Ok(await _inbox.PendingAsync(ct));
|
||||||
|
|
||||||
|
[HttpGet("config")]
|
||||||
|
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ApprovalConfigDto>> GetConfig(CancellationToken ct)
|
||||||
|
=> Ok(await _approvals.GetConfigAsync(ct));
|
||||||
|
|
||||||
|
[HttpPut("config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings.approvals")]
|
||||||
|
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ApprovalConfigDto>> UpdateConfig([FromBody] UpdateApprovalConfigRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _approvals.UpdateConfigAsync(request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Accounts;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Till cash deposited to the bank, and the cash in hand behind it (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3).
|
||||||
|
/// Recording needs the Create action on <c>accounts.bank-deposits</c>; cancelling needs Delete.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/bank-deposits")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits")]
|
||||||
|
public sealed class BankDepositsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBankDepositService _deposits;
|
||||||
|
private readonly ICashInHandService _cash;
|
||||||
|
|
||||||
|
public BankDepositsController(IBankDepositService deposits, ICashInHandService cash)
|
||||||
|
{
|
||||||
|
_deposits = deposits;
|
||||||
|
_cash = cash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deposits filtered by business date range, branch, bank account and status, plus their total.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(BankDepositListDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<BankDepositListDto>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] int? branchId,
|
||||||
|
[FromQuery] long? bankAccountId, [FromQuery] BankDepositStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _deposits.ListAsync(query, new BankDepositListFilter(from, to, branchId, bankAccountId, status), ct));
|
||||||
|
|
||||||
|
[HttpGet("{bankDepositId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<BankDepositDto>> GetById(int bankDepositId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _deposits.GetAsync(bankDepositId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One business day's cash position (defaults to today) and how much of it can still be deposited.</summary>
|
||||||
|
[HttpGet("cash-in-hand")]
|
||||||
|
[ProducesResponseType(typeof(CashInHandDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CashInHandDto>> CashInHand([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
|
=> Ok(await _cash.GetPositionAsync(date, branchId, ct));
|
||||||
|
|
||||||
|
/// <summary>Day-wise cash position for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
|
||||||
|
[HttpGet("cash-in-hand/daily")]
|
||||||
|
[ProducesResponseType(typeof(CashInHandDailyDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<CashInHandDailyDto>> CashInHandDaily(
|
||||||
|
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
|
=> Ok(await _cash.GetDailyAsync(from, to, branchId, ct));
|
||||||
|
|
||||||
|
/// <summary>Record a deposit; posts GL (Debit bank / Credit cash). Rejected above the cash in hand.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
|
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<BankDepositDto>> Create([FromBody] CreateBankDepositRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _deposits.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/bank-deposits/{result.BankDepositId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cancel a Posted deposit; posts the reversing GL entry and puts the cash back in hand.</summary>
|
||||||
|
[HttpPost("{bankDepositId:int}/cancel")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||||
|
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<BankDepositDto>> Cancel(int bankDepositId, [FromBody] CancelBankDepositRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _deposits.CancelAsync(bankDepositId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Dtos.Reports;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Day-End Sales Report — a day's sales, payments by method, expenses and income, and the
|
||||||
|
/// same day-wise over a range for the income chart/PDF (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
|
||||||
|
[Route("api/v1/day-end-report")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "sales.day-end-report")]
|
||||||
|
public sealed class DayEndReportController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDayEndReportService _reports;
|
||||||
|
|
||||||
|
public DayEndReportController(IDayEndReportService reports) => _reports = reports;
|
||||||
|
|
||||||
|
/// <summary>One business day (defaults to today in the report's time zone).</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(DayEndReportDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<DayEndReportDto>> GetDay([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.GetDayAsync(date, branchId, ct));
|
||||||
|
|
||||||
|
/// <summary>Day-wise figures for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
|
||||||
|
[HttpGet("daily")]
|
||||||
|
[ProducesResponseType(typeof(DayEndDailyReportDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<DayEndDailyReportDto>> GetDaily([FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.GetDailyAsync(from, to, branchId, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Dtos.Expenses;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expense categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup"). Reading is open to
|
||||||
|
/// anyone with Accounts access (the expense form needs the lists); changes need the
|
||||||
|
/// Expense Setup permission. Categories are the fixed seeded four — there is no create
|
||||||
|
/// endpoint; only their active flag can change. Titles can be added under them.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "accounts")]
|
||||||
|
public sealed class ExpenseSetupController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private const string SetupPolicy = NavPolicies.Prefix + "accounts.expense-setup";
|
||||||
|
|
||||||
|
private readonly IExpenseSetupService _setup;
|
||||||
|
|
||||||
|
public ExpenseSetupController(IExpenseSetupService setup) => _setup = setup;
|
||||||
|
|
||||||
|
/// <summary>Categories, each with its titles, in display order.</summary>
|
||||||
|
[HttpGet("expense-categories")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<ExpenseCategoryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ExpenseCategoryDto>>> List([FromQuery] bool includeInactive, CancellationToken ct)
|
||||||
|
=> Ok(await _setup.ListAsync(includeInactive, ct));
|
||||||
|
|
||||||
|
[HttpPut("expense-categories/{expenseCategoryId:int}")]
|
||||||
|
[Authorize(Policy = SetupPolicy)]
|
||||||
|
[ProducesResponseType(typeof(ExpenseCategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ExpenseCategoryDto>> UpdateCategory(int expenseCategoryId, [FromBody] UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _setup.UpdateCategoryAsync(expenseCategoryId, request, ct));
|
||||||
|
|
||||||
|
[HttpPost("expense-categories/{expenseCategoryId:int}/titles")]
|
||||||
|
[Authorize(Policy = SetupPolicy)]
|
||||||
|
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ExpenseTitleDto>> CreateTitle(int expenseCategoryId, [FromBody] CreateExpenseTitleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _setup.CreateTitleAsync(expenseCategoryId, request, ct);
|
||||||
|
return Created($"/api/v1/expense-titles/{dto.ExpenseTitleId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("expense-titles/{expenseTitleId:int}")]
|
||||||
|
[Authorize(Policy = SetupPolicy)]
|
||||||
|
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ExpenseTitleDto>> UpdateTitle(int expenseTitleId, [FromBody] UpdateExpenseTitleRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _setup.UpdateTitleAsync(expenseTitleId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Expenses;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Business expense bills — draft, post (GL accrual), pay, cancel (docs/EXPENSE_MODULE_PLAN.md §6).</summary>
|
||||||
|
[Route("api/v1/expenses")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "accounts.expenses")]
|
||||||
|
public sealed class ExpensesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseService _expenses;
|
||||||
|
private readonly IExpensePaymentService _payments;
|
||||||
|
|
||||||
|
public ExpensesController(IExpenseService expenses, IExpensePaymentService payments)
|
||||||
|
{
|
||||||
|
_expenses = expenses;
|
||||||
|
_payments = payments;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ExpenseSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ExpenseSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] ExpenseStatus? status, [FromQuery] ExpensePaymentStatus? paymentStatus,
|
||||||
|
[FromQuery] int? categoryId, [FromQuery] int? titleId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||||
|
[FromQuery] bool overdueOnly, CancellationToken ct)
|
||||||
|
=> Ok(await _expenses.ListAsync(query, new ExpenseListFilter(status, paymentStatus, categoryId, titleId, from, to, overdueOnly), ct));
|
||||||
|
|
||||||
|
/// <summary>Totals by category → title for Posted expenses dated in [from, to] (default: this month), plus unpaid/overdue position.</summary>
|
||||||
|
[HttpGet("summary")]
|
||||||
|
[ProducesResponseType(typeof(ExpenseSummaryReportDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ExpenseSummaryReportDto>> Summary([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||||
|
=> Ok(await _expenses.GetSummaryAsync(from, to, ct));
|
||||||
|
|
||||||
|
[HttpGet("{expenseId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ExpenseDto>> GetById(int expenseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _expenses.GetAsync(expenseId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<ExpenseDto>> Create([FromBody] CreateExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _expenses.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/expenses/{result.Value.ExpenseId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{expenseId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<ExpenseDto>> Update(int expenseId, [FromBody] UpdateExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _expenses.UpdateAsync(expenseId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draft only.</summary>
|
||||||
|
[HttpDelete("{expenseId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int expenseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _expenses.DeleteAsync(expenseId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accrue the expense to GL (Debit expense account / Credit Accrued Expenses).</summary>
|
||||||
|
[HttpPost("{expenseId:int}/post")]
|
||||||
|
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ExpenseDto>> Post(int expenseId, CancellationToken ct)
|
||||||
|
=> Ok(await _expenses.PostAsync(expenseId, ct));
|
||||||
|
|
||||||
|
/// <summary>Post a Draft and pay it, in full or partially, in one transaction; any remainder stays as the balance.</summary>
|
||||||
|
[HttpPost("{expenseId:int}/post-and-pay")]
|
||||||
|
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ExpenseDto>> PostAndPay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _payments.PostAndPayAsync(expenseId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>Draft, or Posted with no payments (posts the reversing GL entry).</summary>
|
||||||
|
[HttpPost("{expenseId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ExpenseDto>> Cancel(int expenseId, [FromBody] CancelExpenseRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _expenses.CancelAsync(expenseId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>Pay against the balance, in full or in installments; one GL entry per submission.</summary>
|
||||||
|
[HttpPost("{expenseId:int}/payments")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> Pay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rows = await _payments.PayAsync(expenseId, request, ct);
|
||||||
|
return Created($"/api/v1/expenses/{expenseId}/payments", rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Payment history, newest first.</summary>
|
||||||
|
[HttpGet("{expenseId:int}/payments")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> ListPayments(int expenseId, CancellationToken ct)
|
||||||
|
=> Ok(await _payments.ListAsync(expenseId, ct));
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@ public sealed class ItemsController : ApiControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The item's latest posted GRN receipts (default 5, newest first): unit cost, discount, net cost, vendor.</summary>
|
||||||
|
[HttpGet("{itemId:int}/purchase-prices")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<ItemPurchasePriceDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ItemPurchasePriceDto>>> PurchasePrices(int itemId, [FromQuery] int take, CancellationToken ct)
|
||||||
|
=> Ok(await _items.GetPurchasePricesAsync(itemId, take <= 0 ? 5 : take, ct));
|
||||||
|
|
||||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||||
[HttpGet("{itemId:int}")]
|
[HttpGet("{itemId:int}")]
|
||||||
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Products;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6.4). Requesting needs Create on
|
||||||
|
/// <c>products.price-adjustments</c>, deciding needs Approve; an item's applied price history is
|
||||||
|
/// readable by anyone who can see Products.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/price-adjustments")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||||
|
public sealed class PriceAdjustmentsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private const string Screen = NavPolicies.Prefix + "products.price-adjustments";
|
||||||
|
private const string CreatePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.CreateAction;
|
||||||
|
private const string ApprovePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
|
||||||
|
|
||||||
|
private readonly IPriceAdjustmentService _prices;
|
||||||
|
|
||||||
|
public PriceAdjustmentsController(IPriceAdjustmentService prices) => _prices = prices;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Policy = Screen)]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<PriceAdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<PriceAdjustmentSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] PriceAdjustmentStatus? status, [FromQuery] int? itemId, CancellationToken ct)
|
||||||
|
=> Ok(await _prices.ListAsync(query, status, itemId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{priceAdjustmentId:int}")]
|
||||||
|
[Authorize(Policy = Screen)]
|
||||||
|
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PriceAdjustmentDto>> GetById(int priceAdjustmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _prices.GetAsync(priceAdjustmentId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Request new selling prices for one or more items.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = CreatePolicy)]
|
||||||
|
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PriceAdjustmentDto>> Create([FromBody] CreatePriceAdjustmentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _prices.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/price-adjustments/{dto.PriceAdjustmentId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Give the next approval; the final one applies the prices.</summary>
|
||||||
|
[HttpPost("{priceAdjustmentId:int}/approve")]
|
||||||
|
[Authorize(Policy = ApprovePolicy)]
|
||||||
|
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PriceAdjustmentDto>> Approve(int priceAdjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
|
||||||
|
=> Ok(await _prices.ApproveAsync(priceAdjustmentId, request?.Comment, ct));
|
||||||
|
|
||||||
|
[HttpPost("{priceAdjustmentId:int}/reject")]
|
||||||
|
[Authorize(Policy = ApprovePolicy)]
|
||||||
|
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PriceAdjustmentDto>> Reject(int priceAdjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _prices.RejectAsync(priceAdjustmentId, request.Reason, ct));
|
||||||
|
|
||||||
|
/// <summary>The requester withdraws a pending request.</summary>
|
||||||
|
[HttpPost("{priceAdjustmentId:int}/cancel")]
|
||||||
|
[Authorize(Policy = Screen)]
|
||||||
|
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PriceAdjustmentDto>> Cancel(int priceAdjustmentId, CancellationToken ct)
|
||||||
|
=> Ok(await _prices.CancelAsync(priceAdjustmentId, ct));
|
||||||
|
|
||||||
|
/// <summary>Every applied price change for an item, newest first, with who approved it.</summary>
|
||||||
|
[HttpGet("/api/v1/items/{itemId:int}/price-history")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<ItemPriceHistoryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ItemPriceHistoryDto>>> ItemHistory(int itemId, CancellationToken ct)
|
||||||
|
=> Ok(await _prices.ItemHistoryAsync(itemId, ct));
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -7,23 +9,30 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
/// <summary>
|
||||||
|
/// Stock-adjustment endpoints (docs/11 §5.5). Creating submits a request; it moves stock once
|
||||||
|
/// approved (docs/DUAL_APPROVAL_PLAN.md §5.4). Approving or rejecting needs the Approve action on
|
||||||
|
/// <c>stock.adjustments</c>; the two-person rules are enforced by the service.
|
||||||
|
/// </summary>
|
||||||
[Route("api/v1/stock-adjustments")]
|
[Route("api/v1/stock-adjustments")]
|
||||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||||
public sealed class StockAdjustmentsController : ApiControllerBase
|
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
|
private const string ApprovePolicy = NavPolicies.Prefix + "stock.adjustments" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
|
||||||
|
|
||||||
private readonly IAdjustmentService _adjustments;
|
private readonly IAdjustmentService _adjustments;
|
||||||
|
|
||||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||||
|
|
||||||
/// <summary>List posted adjustments, newest first.</summary>
|
/// <summary>List adjustments (posted and requests), newest first.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
|
||||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
|
[FromQuery] AdjustmentStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct));
|
||||||
|
|
||||||
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
|
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
|
||||||
[HttpGet("{adjustmentId:int}")]
|
[HttpGet("{adjustmentId:int}")]
|
||||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -33,7 +42,7 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
|||||||
return dto is null ? NotFound() : Ok(dto);
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
/// <summary>Submit an adjustment (mandatory reason code). Posts at once only when approvals are off.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
@@ -44,4 +53,29 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
|||||||
var dto = await _adjustments.CreateAsync(request, ct);
|
var dto = await _adjustments.CreateAsync(request, ct);
|
||||||
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
|
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Give the next approval. The final one posts the stock movement and GL.</summary>
|
||||||
|
[HttpPost("{adjustmentId:int}/approve")]
|
||||||
|
[Authorize(Policy = ApprovePolicy)]
|
||||||
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<AdjustmentDto>> Approve(int adjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
|
||||||
|
=> Ok(await _adjustments.ApproveAsync(adjustmentId, request?.Comment, ct));
|
||||||
|
|
||||||
|
[HttpPost("{adjustmentId:int}/reject")]
|
||||||
|
[Authorize(Policy = ApprovePolicy)]
|
||||||
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<AdjustmentDto>> Reject(int adjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _adjustments.RejectAsync(adjustmentId, request.Reason, ct));
|
||||||
|
|
||||||
|
/// <summary>The requester withdraws a pending request.</summary>
|
||||||
|
[HttpPost("{adjustmentId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<AdjustmentDto>> Cancel(int adjustmentId, CancellationToken ct)
|
||||||
|
=> Ok(await _adjustments.CancelAsync(adjustmentId, ct));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,13 @@ public static class DocumentTypes
|
|||||||
|
|
||||||
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
||||||
public const string SalesDayEnd = "DEND";
|
public const string SalesDayEnd = "DEND";
|
||||||
|
|
||||||
|
/// <summary>Business expense bill (Accounts) — <c>EXP-BR-001-2026-00001</c>.</summary>
|
||||||
|
public const string Expense = "EXP";
|
||||||
|
|
||||||
|
/// <summary>Till cash deposited to a bank account (Accounts) — <c>BD-BR-001-2026-00001</c>.</summary>
|
||||||
|
public const string BankDeposit = "BD";
|
||||||
|
|
||||||
|
/// <summary>Selling-price change request (Products) — <c>PADJ-2026-00001</c>.</summary>
|
||||||
|
public const string PriceAdjustment = "PADJ";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Till cash taken to the bank (docs/BANK_DEPOSIT_MODULE_PLAN.md §4). Saving it posts a GL entry
|
||||||
|
/// (Debit the bank account / Credit the cash account) and lowers the branch's cash in hand for
|
||||||
|
/// <see cref="BusinessDate"/>. There is no draft: a wrong deposit is cancelled (the entry is
|
||||||
|
/// reversed) and entered again. GL accounts have no local FK, so the account details are snapshots.
|
||||||
|
/// </summary>
|
||||||
|
public class BankDeposit
|
||||||
|
{
|
||||||
|
public int BankDepositId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The day whose cash is deposited — cash in hand and the day-end report group by this.</summary>
|
||||||
|
public DateOnly BusinessDate { get; set; }
|
||||||
|
/// <summary>The day the bank received it; the GL entry date.</summary>
|
||||||
|
public DateOnly DepositDate { get; set; }
|
||||||
|
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
public long FromGlCashAccountId { get; set; }
|
||||||
|
public string FromCashAccountCode { get; set; } = string.Empty;
|
||||||
|
public string FromCashAccountName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public long ToGlBankAccountId { get; set; }
|
||||||
|
public string ToBankAccountCode { get; set; } = string.Empty;
|
||||||
|
public string ToBankAccountName { get; set; } = string.Empty;
|
||||||
|
public string? ToBankName { get; set; }
|
||||||
|
public string? ToAccountNumber { get; set; }
|
||||||
|
|
||||||
|
public string? DepositSlipNo { get; set; }
|
||||||
|
public string? DepositedBy { get; set; }
|
||||||
|
public string? Remarks { get; set; }
|
||||||
|
|
||||||
|
public BankDepositStatus Status { get; set; } = BankDepositStatus.Posted;
|
||||||
|
|
||||||
|
public string? GlJournalNo { get; set; }
|
||||||
|
public DateTime? GlPostedAt { get; set; }
|
||||||
|
|
||||||
|
public string? GlReversalJournalNo { get; set; }
|
||||||
|
public string? CancelReason { get; set; }
|
||||||
|
public int? CancelledBy { get; set; }
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public User? Creator { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@ public class BundleSale
|
|||||||
public decimal DiscountTotal { get; set; }
|
public decimal DiscountTotal { get; set; }
|
||||||
public decimal TaxTotal { get; set; }
|
public decimal TaxTotal { get; set; }
|
||||||
public decimal GrandTotal { get; set; }
|
public decimal GrandTotal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How the (always fully paid) <see cref="GrandTotal"/> was tendered:
|
||||||
|
/// <c>CashAmount + CardAmount == GrandTotal</c>. Backfilled as all cash.</summary>
|
||||||
|
public decimal CashAmount { get; set; }
|
||||||
|
public decimal CardAmount { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public uint RowVersion { get; set; }
|
public uint RowVersion { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One approval decision on a document (docs/DUAL_APPROVAL_PLAN.md §4.1) — a row per decision, so
|
||||||
|
/// the full trail survives. Polymorphic like the stock ledger: <see cref="DocType"/> +
|
||||||
|
/// <see cref="DocId"/>, no FK. Doc types: <c>ADJ</c> (stock adjustment), <c>PADJ</c> (price adjustment).
|
||||||
|
/// </summary>
|
||||||
|
public class DocumentApproval
|
||||||
|
{
|
||||||
|
public int DocumentApprovalId { get; set; }
|
||||||
|
public string DocType { get; set; } = string.Empty;
|
||||||
|
public int DocId { get; set; }
|
||||||
|
/// <summary>1 or 2.</summary>
|
||||||
|
public int Level { get; set; }
|
||||||
|
public ApprovalDecision Decision { get; set; }
|
||||||
|
public int DecidedBy { get; set; }
|
||||||
|
public User? Decider { get; set; }
|
||||||
|
public DateTime DecidedAt { get; set; }
|
||||||
|
public string? Comment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Approval rules (docs/DUAL_APPROVAL_PLAN.md §7). Singleton row, edited under Settings → Approvals.
|
||||||
|
/// Levels: 0 = no approval (applies on create), 1 = one approver, 2 = two different approvers.
|
||||||
|
/// </summary>
|
||||||
|
public class ApprovalConfig
|
||||||
|
{
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ApprovalConfigId { get; set; }
|
||||||
|
|
||||||
|
public int StockAdjustmentLevels { get; set; } = 2;
|
||||||
|
/// <summary>With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.</summary>
|
||||||
|
public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
|
||||||
|
|
||||||
|
public int PriceAdjustmentLevels { get; set; } = 2;
|
||||||
|
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
|
||||||
|
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary>
|
||||||
|
public string? Level2RoleCodes { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One business expense bill (electricity, rent, …) outside procurement. Draft is editable;
|
||||||
|
/// Post accrues it to GL (Debit the title's expense account / Credit Accrued Expenses);
|
||||||
|
/// payments then settle the accrual in one or more installments (see
|
||||||
|
/// ExpensePaymentService). Model: docs/EXPENSE_MODULE_PLAN.md §3.3.
|
||||||
|
/// </summary>
|
||||||
|
public class Expense
|
||||||
|
{
|
||||||
|
public int ExpenseId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
|
||||||
|
public DateOnly ExpenseDate { get; set; }
|
||||||
|
public DateOnly? DueDate { get; set; }
|
||||||
|
|
||||||
|
public int ExpenseCategoryId { get; set; }
|
||||||
|
public ExpenseCategory? Category { get; set; }
|
||||||
|
|
||||||
|
public int ExpenseTitleId { get; set; }
|
||||||
|
public ExpenseTitle? Title { get; set; }
|
||||||
|
|
||||||
|
public string? Payee { get; set; }
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public Vendor? Vendor { get; set; }
|
||||||
|
|
||||||
|
public string? BillReference { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
/// <summary>Optional month the bill covers, as <c>yyyy-MM</c> (e.g. September's electricity).</summary>
|
||||||
|
public string? PeriodMonth { get; set; }
|
||||||
|
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public decimal PaidAmount { get; set; }
|
||||||
|
public decimal BalanceAmount { get; set; }
|
||||||
|
|
||||||
|
public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
|
||||||
|
public ExpensePaymentStatus PaymentStatus { get; set; } = ExpensePaymentStatus.Unpaid;
|
||||||
|
|
||||||
|
/// <summary>Snapshot of the title's GL expense account at post time.</summary>
|
||||||
|
public string? GlExpenseAccountCode { get; set; }
|
||||||
|
/// <summary>Journal number of the accrual entry posted on Post.</summary>
|
||||||
|
public string? GlJournalNo { get; set; }
|
||||||
|
public DateTime? GlPostedAt { get; set; }
|
||||||
|
/// <summary>Journal number of the reversing entry posted on Cancel (Posted expenses only).</summary>
|
||||||
|
public string? GlReversalJournalNo { get; set; }
|
||||||
|
public string? CancelReason { get; set; }
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public User? Creator { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ExpensePayment> Payments { get; set; } = new List<ExpensePayment>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A payment against a Posted expense's balance (installments allowed). Every line of one
|
||||||
|
/// payment submission shares the single GL journal entry that submission posted (Debit
|
||||||
|
/// Accrued Expenses / Credit each selected cash-or-bank account). Mirrors
|
||||||
|
/// <see cref="PurchaseInvoicePayment"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpensePayment
|
||||||
|
{
|
||||||
|
public int ExpensePaymentId { get; set; }
|
||||||
|
|
||||||
|
public int ExpenseId { get; set; }
|
||||||
|
public Expense? Expense { get; set; }
|
||||||
|
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public DateTime PaymentDate { get; set; }
|
||||||
|
public ExpensePaymentMethod PaymentMethod { get; set; }
|
||||||
|
|
||||||
|
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
|
||||||
|
public long GlBankAccountId { get; set; }
|
||||||
|
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
||||||
|
public string BankAccountName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? ChequeNo { get; set; }
|
||||||
|
public string? Reference { get; set; }
|
||||||
|
public string? GlJournalNo { get; set; }
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public User? Creator { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level expense grouping (Utility, Rental, Salaries, Other). The four seeded rows are
|
||||||
|
/// <see cref="IsSystem"/> — they can be deactivated but not renamed; admins can add more.
|
||||||
|
/// Each category owns its <see cref="ExpenseTitle"/>s. Model: docs/EXPENSE_MODULE_PLAN.md §3.1.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseCategory
|
||||||
|
{
|
||||||
|
public int ExpenseCategoryId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ExpenseTitle> Titles { get; set; } = new List<ExpenseTitle>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A specific expense under a category (Utility → Electricity, Water, …). Carries the GL
|
||||||
|
/// expense account debited when an expense with this title is posted — snapshotted onto the
|
||||||
|
/// expense at post time, so changing it later does not touch already-posted expenses.
|
||||||
|
/// Model: docs/EXPENSE_MODULE_PLAN.md §3.2.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseTitle
|
||||||
|
{
|
||||||
|
public int ExpenseTitleId { get; set; }
|
||||||
|
|
||||||
|
public int ExpenseCategoryId { get; set; }
|
||||||
|
public ExpenseCategory? Category { get; set; }
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string GlExpenseAccountCode { get; set; } = string.Empty;
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A request to change items' selling prices (docs/DUAL_APPROVAL_PLAN.md §6). Nothing changes on the
|
||||||
|
/// items until it is fully approved; then each line's new prices are written to its item and an
|
||||||
|
/// <see cref="ItemPriceHistory"/> row is kept. <see cref="CreatedBy"/> is the requester.
|
||||||
|
/// </summary>
|
||||||
|
public class PriceAdjustment
|
||||||
|
{
|
||||||
|
public int PriceAdjustmentId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public PriceAdjustmentStatus Status { get; set; } = PriceAdjustmentStatus.PendingApproval;
|
||||||
|
public int RequiredApprovals { get; set; }
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public User? Creator { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public int? ApprovedLevel1By { get; set; }
|
||||||
|
public DateTime? ApprovedLevel1At { get; set; }
|
||||||
|
public int? ApprovedLevel2By { get; set; }
|
||||||
|
public DateTime? ApprovedLevel2At { get; set; }
|
||||||
|
public int? RejectedBy { get; set; }
|
||||||
|
public DateTime? RejectedAt { get; set; }
|
||||||
|
public string? RejectReason { get; set; }
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
/// <summary>When the new prices were written to the items.</summary>
|
||||||
|
public DateTime? AppliedAt { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<PriceAdjustmentLine> Lines { get; set; } = new List<PriceAdjustmentLine>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One item's change. The Old* values are the item's prices when the request was made —
|
||||||
|
/// approval fails if the item has been repriced since (stale request).</summary>
|
||||||
|
public class PriceAdjustmentLine
|
||||||
|
{
|
||||||
|
public int PriceAdjustmentLineId { get; set; }
|
||||||
|
public int PriceAdjustmentId { get; set; }
|
||||||
|
public PriceAdjustment? PriceAdjustment { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public decimal? OldMinPrice { get; set; }
|
||||||
|
public decimal? OldMaxPrice { get; set; }
|
||||||
|
public decimal? OldFlatPrice { get; set; }
|
||||||
|
public decimal? NewMinPrice { get; set; }
|
||||||
|
public decimal? NewMaxPrice { get; set; }
|
||||||
|
public decimal? NewFlatPrice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An applied selling-price change: what it was, what it became, and who approved it.</summary>
|
||||||
|
public class ItemPriceHistory
|
||||||
|
{
|
||||||
|
public int ItemPriceHistoryId { get; set; }
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
public int? PriceAdjustmentId { get; set; }
|
||||||
|
public PriceAdjustment? PriceAdjustment { get; set; }
|
||||||
|
|
||||||
|
public decimal? OldMinPrice { get; set; }
|
||||||
|
public decimal? OldMaxPrice { get; set; }
|
||||||
|
public decimal? OldFlatPrice { get; set; }
|
||||||
|
public decimal? NewMinPrice { get; set; }
|
||||||
|
public decimal? NewMaxPrice { get; set; }
|
||||||
|
public decimal? NewFlatPrice { get; set; }
|
||||||
|
|
||||||
|
public int RequestedBy { get; set; }
|
||||||
|
public int? ApprovedLevel1By { get; set; }
|
||||||
|
public int? ApprovedLevel2By { get; set; }
|
||||||
|
public DateTime AppliedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -21,6 +23,9 @@ public class SalesInvoicePayment
|
|||||||
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
||||||
public string BankAccountName { get; set; } = string.Empty;
|
public string BankAccountName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>How the customer paid. Payments recorded before this existed were backfilled as Cash.</summary>
|
||||||
|
public SalesPaymentMethod PaymentMethod { get; set; } = SalesPaymentMethod.Cash;
|
||||||
|
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
|
|
||||||
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
|
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public class SalesSlip
|
|||||||
public decimal PaidAmount { get; set; }
|
public decimal PaidAmount { get; set; }
|
||||||
public decimal BalanceAmount { get; set; }
|
public decimal BalanceAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How <see cref="PaidAmount"/> was tendered: <c>CashAmount + CardAmount == PaidAmount</c>.
|
||||||
|
/// Slips from before the split existed were backfilled as all cash.</summary>
|
||||||
|
public decimal CashAmount { get; set; }
|
||||||
|
public decimal CardAmount { get; set; }
|
||||||
|
|
||||||
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
|
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
|
||||||
public int? DayEndId { get; set; }
|
public int? DayEndId { get; set; }
|
||||||
public SalesDayEnd? DayEnd { get; set; }
|
public SalesDayEnd? DayEnd { get; set; }
|
||||||
|
|||||||
@@ -37,5 +37,20 @@ public class StockAdjustment
|
|||||||
public string? GlJournalNo { get; set; }
|
public string? GlJournalNo { get; set; }
|
||||||
public DateTime? GlPostedAt { get; set; }
|
public DateTime? GlPostedAt { get; set; }
|
||||||
|
|
||||||
|
// --- Approval (docs/DUAL_APPROVAL_PLAN.md §5). CreatedBy is the requester. ---
|
||||||
|
|
||||||
|
/// <summary>Approvals this request needs (0 = posted on create, 1 or 2), fixed when it is made.</summary>
|
||||||
|
public int RequiredApprovals { get; set; }
|
||||||
|
public int? ApprovedLevel1By { get; set; }
|
||||||
|
public DateTime? ApprovedLevel1At { get; set; }
|
||||||
|
public int? ApprovedLevel2By { get; set; }
|
||||||
|
public DateTime? ApprovedLevel2At { get; set; }
|
||||||
|
public int? RejectedBy { get; set; }
|
||||||
|
public DateTime? RejectedAt { get; set; }
|
||||||
|
public string? RejectReason { get; set; }
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
/// <summary>When stock actually moved — the ledger and GL use this, not <see cref="CreatedAt"/>.</summary>
|
||||||
|
public DateTime? PostedAt { get; set; }
|
||||||
|
|
||||||
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,12 @@ public class StockAdjustmentLine
|
|||||||
public Serial? Serial { get; set; }
|
public Serial? Serial { get; set; }
|
||||||
|
|
||||||
public decimal QtyDelta { get; set; }
|
public decimal QtyDelta { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The serials named on the request, kept until it posts (JSON arrays): outbound serial ids
|
||||||
|
/// to write off, inbound serial numbers found. Re-validated at posting, because a pending
|
||||||
|
/// request moves nothing and the serials may have moved in the meantime.
|
||||||
|
/// </summary>
|
||||||
|
public string? RequestedSerialIds { get; set; }
|
||||||
|
public string? RequestedSerialNos { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
namespace ERPCore.Domain.Enums;
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
|
/// Stock-adjustment lifecycle (docs/DUAL_APPROVAL_PLAN.md §5.1). A new adjustment is a request
|
||||||
/// <see cref="PendingApproval"/> is reserved for the future threshold-approval
|
/// (<see cref="PendingApproval"/>, then <see cref="PendingSecondApproval"/> when two approvals are
|
||||||
/// workflow (FR-STK-07). Stored as a string.
|
/// required) and only moves stock when it becomes <see cref="Posted"/>. With approvals switched off
|
||||||
|
/// (<c>ApprovalConfig.StockAdjustmentLevels = 0</c>) it posts on create, as before. Stored as a string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum AdjustmentStatus
|
public enum AdjustmentStatus
|
||||||
{
|
{
|
||||||
Draft,
|
Draft,
|
||||||
PendingApproval,
|
PendingApproval,
|
||||||
Posted
|
Posted,
|
||||||
|
PendingSecondApproval,
|
||||||
|
Rejected,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Price-adjustment lifecycle — same request flow as <see cref="AdjustmentStatus"/>; <see cref="Approved"/> means applied.</summary>
|
||||||
|
public enum PriceAdjustmentStatus
|
||||||
|
{
|
||||||
|
PendingApproval,
|
||||||
|
PendingSecondApproval,
|
||||||
|
Approved,
|
||||||
|
Rejected,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ApprovalDecision
|
||||||
|
{
|
||||||
|
Approved,
|
||||||
|
Rejected
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>A deposit is posted as soon as it is saved; a wrong one is cancelled (GL reversed) and re-entered.</summary>
|
||||||
|
public enum BankDepositStatus
|
||||||
|
{
|
||||||
|
Posted,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum ExpenseStatus
|
||||||
|
{
|
||||||
|
Draft,
|
||||||
|
Posted,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Settlement state of a Posted expense, derived from its paid/balance amounts.</summary>
|
||||||
|
public enum ExpensePaymentStatus
|
||||||
|
{
|
||||||
|
Unpaid,
|
||||||
|
PartiallyPaid,
|
||||||
|
Paid
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExpensePaymentMethod
|
||||||
|
{
|
||||||
|
Cash,
|
||||||
|
BankTransfer,
|
||||||
|
Cheque,
|
||||||
|
Card
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>How a customer paid for a sale — recorded per invoice payment (and split into
|
||||||
|
/// cash/card amounts on slips and bundle sales) so the Day-End Sales Report can break
|
||||||
|
/// collections down by method. Mirrors <see cref="ExpensePaymentMethod"/>.</summary>
|
||||||
|
public enum SalesPaymentMethod
|
||||||
|
{
|
||||||
|
Cash,
|
||||||
|
Card,
|
||||||
|
BankTransfer,
|
||||||
|
Cheque
|
||||||
|
}
|
||||||
@@ -27,6 +27,18 @@ public sealed class StockAdjustmentCreatedNotificationModule;
|
|||||||
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
|
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
|
||||||
public sealed class StockTransferCreatedNotificationModule;
|
public sealed class StockTransferCreatedNotificationModule;
|
||||||
|
|
||||||
|
[NotificationModule("stock.low", "Low Stock / Reorder Alert", "Raised when a sale, transfer or adjustment takes an item's stock at a warehouse to or below its reorder point.", 95)]
|
||||||
|
public sealed class StockLowNotificationModule;
|
||||||
|
|
||||||
|
[NotificationModule("approval.pending", "Approval Needed", "Sent to the people who can approve a stock or price adjustment that is waiting for them.", 96)]
|
||||||
|
public sealed class ApprovalPendingNotificationModule;
|
||||||
|
|
||||||
|
[NotificationModule("approval.approved", "Request Approved", "Sent to the requester when their stock or price adjustment is fully approved and applied.", 97)]
|
||||||
|
public sealed class ApprovalApprovedNotificationModule;
|
||||||
|
|
||||||
|
[NotificationModule("approval.rejected", "Request Rejected", "Sent to the requester when their stock or price adjustment is rejected.", 98)]
|
||||||
|
public sealed class ApprovalRejectedNotificationModule;
|
||||||
|
|
||||||
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
|
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
|
||||||
public sealed class ItemCreatedNotificationModule;
|
public sealed class ItemCreatedNotificationModule;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Accounts;
|
||||||
|
|
||||||
|
// --- Cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §7.1) ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One business day's till cash. CashIn is slip/bundle cash plus cash invoice payments received
|
||||||
|
/// that day; CashOut is expense payments made in cash; Deposited is Posted bank deposits for that
|
||||||
|
/// business day. Closing = Opening + NetCash − Deposited, and becomes the next day's Opening.
|
||||||
|
/// Days before the tracking start date carry nothing in: their Opening is 0.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CashPositionDto(
|
||||||
|
DateOnly Date, decimal Opening, decimal CashIn, decimal CashOut, decimal NetCash, decimal Deposited, decimal Closing);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A day's cash position for the deposit form. <paramref name="AvailableToDeposit"/> is the lowest
|
||||||
|
/// closing balance from that day up to today: a back-dated deposit lowers every later day too.
|
||||||
|
/// <paramref name="DefaultCashAccountCode"/> is the GL cash account the form preselects.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CashInHandDto(
|
||||||
|
CashPositionDto Position, decimal AvailableToDeposit, int? BranchId, DateOnly? TrackingStartDate, string TimeZone,
|
||||||
|
string? DefaultCashAccountCode);
|
||||||
|
|
||||||
|
public sealed record CashInHandDailyDto(
|
||||||
|
DateOnly From, DateOnly To, IReadOnlyList<CashPositionDto> Days, int? BranchId, DateOnly? TrackingStartDate, string TimeZone);
|
||||||
|
|
||||||
|
// --- Deposits ---
|
||||||
|
|
||||||
|
public sealed record BankDepositDto(
|
||||||
|
int BankDepositId, string DocNo, int BranchId, string? BranchName,
|
||||||
|
DateOnly BusinessDate, DateOnly DepositDate, decimal Amount,
|
||||||
|
long FromGlCashAccountId, string FromCashAccountCode, string FromCashAccountName,
|
||||||
|
long ToGlBankAccountId, string ToBankAccountCode, string ToBankAccountName, string? ToBankName, string? ToAccountNumber,
|
||||||
|
string? DepositSlipNo, string? DepositedBy, string? Remarks,
|
||||||
|
BankDepositStatus Status, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
|
string? GlReversalJournalNo, string? CancelReason, DateTime? CancelledAt,
|
||||||
|
int CreatedBy, DateTime CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>A page of deposits plus the total amount of every row matching the filter (not just this page).</summary>
|
||||||
|
public sealed record BankDepositListDto(IReadOnlyList<BankDepositDto> Items, PaginationDto Pagination, decimal TotalAmount);
|
||||||
|
|
||||||
|
public sealed record BankDepositListFilter(
|
||||||
|
DateOnly? From, DateOnly? To, int? BranchId, long? BankAccountId, BankDepositStatus? Status);
|
||||||
|
|
||||||
|
public sealed class CreateBankDepositRequest
|
||||||
|
{
|
||||||
|
/// <summary>Required when branching is enabled; otherwise the default branch is used. A branch user always deposits for their own branch.</summary>
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
[Required] public DateOnly BusinessDate { get; set; }
|
||||||
|
/// <summary>When the bank received it; also the GL entry date. Omitted ⇒ the business date.</summary>
|
||||||
|
public DateOnly? DepositDate { get; set; }
|
||||||
|
[Range(0.01, 999_999_999_999)] public decimal Amount { get; set; }
|
||||||
|
[Required] public long FromGlCashAccountId { get; set; }
|
||||||
|
[Required] public long ToGlBankAccountId { get; set; }
|
||||||
|
[StringLength(50)] public string? DepositSlipNo { get; set; }
|
||||||
|
[StringLength(150)] public string? DepositedBy { get; set; }
|
||||||
|
[StringLength(500)] public string? Remarks { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CancelBankDepositRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Approvals;
|
||||||
|
|
||||||
|
/// <summary>One decision in a document's approval trail.</summary>
|
||||||
|
public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int DecidedBy, string? DecidedByName, DateTime DecidedAt, string? Comment);
|
||||||
|
|
||||||
|
/// <summary>Settings → Approvals (docs/DUAL_APPROVAL_PLAN.md §7).</summary>
|
||||||
|
public sealed record ApprovalConfigDto(
|
||||||
|
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
|
||||||
|
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
|
||||||
|
IReadOnlyList<string> Level2RoleCodes, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
public sealed class UpdateApprovalConfigRequest
|
||||||
|
{
|
||||||
|
[Range(0, 2)] public int StockAdjustmentLevels { get; set; }
|
||||||
|
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
|
||||||
|
[Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
|
||||||
|
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
|
||||||
|
public List<string> Level2RoleCodes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApproveRequest
|
||||||
|
{
|
||||||
|
[StringLength(250)] public string? Comment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RejectRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A request waiting in the Approvals inbox. <paramref name="Level"/> is the approval it waits for
|
||||||
|
/// (1 or 2) of <paramref name="RequiredApprovals"/>; <paramref name="CanApprove"/> says whether the
|
||||||
|
/// current user may decide it (not the requester, not the level-1 approver, has the right role).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PendingApprovalDto(
|
||||||
|
string DocType, int DocId, string DocNo, string Summary, int Level, int RequiredApprovals,
|
||||||
|
int RequestedBy, string? RequestedByName, DateTime RequestedAt, bool CanApprove, string? WhyNot);
|
||||||
|
|
||||||
|
public sealed record PendingApprovalsDto(IReadOnlyList<PendingApprovalDto> Items, int ActionableCount);
|
||||||
@@ -4,6 +4,9 @@ using ERPCore.Domain.Enums;
|
|||||||
namespace ERPCore.Dtos.Customers;
|
namespace ERPCore.Dtos.Customers;
|
||||||
|
|
||||||
/// <summary>Customer resource used by sales documents.</summary>
|
/// <summary>Customer resource used by sales documents.</summary>
|
||||||
|
/// <param name="OutstandingAmount">Unpaid balance across the customer's Posted sales invoices. Filled on the list only.</param>
|
||||||
|
/// <param name="OutstandingInvoiceCount">Posted sales invoices with a balance still due. Filled on the list only.</param>
|
||||||
|
/// <param name="LastPaymentDate">When the customer last paid against an invoice; null if never. Filled on the list only.</param>
|
||||||
public sealed record CustomerDto(
|
public sealed record CustomerDto(
|
||||||
int CustomerId,
|
int CustomerId,
|
||||||
string CustomerCode,
|
string CustomerCode,
|
||||||
@@ -23,7 +26,10 @@ public sealed record CustomerDto(
|
|||||||
EntityStatus Status,
|
EntityStatus Status,
|
||||||
int BranchId,
|
int BranchId,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime? UpdatedAt);
|
DateTime? UpdatedAt,
|
||||||
|
decimal OutstandingAmount = 0m,
|
||||||
|
int OutstandingInvoiceCount = 0,
|
||||||
|
DateTime? LastPaymentDate = null);
|
||||||
|
|
||||||
public sealed class CreateCustomerRequest
|
public sealed class CreateCustomerRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Expenses;
|
||||||
|
|
||||||
|
// --- Setup: categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup") ---
|
||||||
|
|
||||||
|
public sealed record ExpenseTitleDto(
|
||||||
|
int ExpenseTitleId, int ExpenseCategoryId, string Name, string GlExpenseAccountCode,
|
||||||
|
bool IsSystem, EntityStatus Status, int SortOrder);
|
||||||
|
|
||||||
|
public sealed record ExpenseCategoryDto(
|
||||||
|
int ExpenseCategoryId, string Code, string Name, bool IsSystem, EntityStatus Status, int SortOrder,
|
||||||
|
IReadOnlyList<ExpenseTitleDto> Titles);
|
||||||
|
|
||||||
|
public sealed class UpdateExpenseCategoryRequest
|
||||||
|
{
|
||||||
|
/// <summary>Ignored-if-unchanged for system rows; a different name on a system row is rejected.</summary>
|
||||||
|
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateExpenseTitleRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
|
||||||
|
[Required, StringLength(20, MinimumLength = 1)] public string GlExpenseAccountCode { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateExpenseTitleRequest
|
||||||
|
{
|
||||||
|
/// <summary>Ignored-if-unchanged for system rows; a different name on a system row is rejected.</summary>
|
||||||
|
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
|
||||||
|
[Required, StringLength(20, MinimumLength = 1)] public string GlExpenseAccountCode { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Expenses ---
|
||||||
|
|
||||||
|
public sealed record ExpenseSummaryDto(
|
||||||
|
int ExpenseId, string DocNo, int BranchId, DateOnly ExpenseDate, DateOnly? DueDate,
|
||||||
|
int ExpenseCategoryId, string CategoryName, int ExpenseTitleId, string TitleName,
|
||||||
|
string? Payee, string? BillReference, string? PeriodMonth,
|
||||||
|
decimal Amount, decimal PaidAmount, decimal BalanceAmount,
|
||||||
|
ExpenseStatus Status, ExpensePaymentStatus PaymentStatus, string? GlJournalNo, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public sealed record ExpensePaymentDto(
|
||||||
|
int ExpensePaymentId, int ExpenseId, decimal Amount, DateTime PaymentDate, ExpensePaymentMethod PaymentMethod,
|
||||||
|
long GlBankAccountId, string BankAccountName, string? ChequeNo, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public sealed record ExpenseDto(
|
||||||
|
int ExpenseId, string DocNo, int BranchId, DateOnly ExpenseDate, DateOnly? DueDate,
|
||||||
|
int ExpenseCategoryId, string CategoryName, int ExpenseTitleId, string TitleName,
|
||||||
|
string? Payee, int? VendorId, string? VendorName, string? BillReference, string? Description, string? PeriodMonth,
|
||||||
|
decimal Amount, decimal PaidAmount, decimal BalanceAmount,
|
||||||
|
ExpenseStatus Status, ExpensePaymentStatus PaymentStatus,
|
||||||
|
string? GlExpenseAccountCode, string? GlJournalNo, DateTime? GlPostedAt, string? GlReversalJournalNo, string? CancelReason,
|
||||||
|
int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
|
IReadOnlyList<ExpensePaymentDto> Payments);
|
||||||
|
|
||||||
|
public class ExpenseRequestBase
|
||||||
|
{
|
||||||
|
/// <summary>Required when branching is enabled; otherwise the default branch is used.</summary>
|
||||||
|
public int BranchId { get; set; }
|
||||||
|
[Required] public DateOnly ExpenseDate { get; set; }
|
||||||
|
public DateOnly? DueDate { get; set; }
|
||||||
|
[Required] public int ExpenseCategoryId { get; set; }
|
||||||
|
[Required] public int ExpenseTitleId { get; set; }
|
||||||
|
[StringLength(150)] public string? Payee { get; set; }
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
[StringLength(50)] public string? BillReference { get; set; }
|
||||||
|
[StringLength(500)] public string? Description { get; set; }
|
||||||
|
[RegularExpression(@"^\d{4}-(0[1-9]|1[0-2])$", ErrorMessage = "PeriodMonth must be yyyy-MM.")]
|
||||||
|
public string? PeriodMonth { get; set; }
|
||||||
|
[Range(0.01, 999_999_999_999)] public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateExpenseRequest : ExpenseRequestBase;
|
||||||
|
|
||||||
|
public sealed class UpdateExpenseRequest : ExpenseRequestBase;
|
||||||
|
|
||||||
|
public sealed class CancelExpenseRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExpensePaymentLineRequest
|
||||||
|
{
|
||||||
|
[Required] public long GlBankAccountId { get; set; }
|
||||||
|
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One payment submission: one or more lines (e.g. part cash, part bank), all posted in a
|
||||||
|
/// single GL journal entry. Total must not exceed the balance (for Post & Pay, the amount); partial is fine.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateExpensePaymentRequest
|
||||||
|
{
|
||||||
|
[Required, MinLength(1)] public List<ExpensePaymentLineRequest> Lines { get; set; } = new();
|
||||||
|
public ExpensePaymentMethod PaymentMethod { get; set; } = ExpensePaymentMethod.Cash;
|
||||||
|
/// <summary>Required when <see cref="PaymentMethod"/> is Cheque.</summary>
|
||||||
|
[StringLength(30)] public string? ChequeNo { get; set; }
|
||||||
|
[StringLength(100)] public string? Reference { get; set; }
|
||||||
|
/// <summary>When it was paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||||
|
public DateOnly? PaymentDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExpenseListFilter(
|
||||||
|
ExpenseStatus? Status, ExpensePaymentStatus? PaymentStatus, int? CategoryId, int? TitleId,
|
||||||
|
DateOnly? From, DateOnly? To, bool OverdueOnly);
|
||||||
|
|
||||||
|
public sealed record ExpenseTitleTotalDto(int ExpenseTitleId, string TitleName, decimal Amount, int Count);
|
||||||
|
|
||||||
|
public sealed record ExpenseCategoryTotalDto(
|
||||||
|
int ExpenseCategoryId, string CategoryName, decimal Amount, int Count, IReadOnlyList<ExpenseTitleTotalDto> Titles);
|
||||||
|
|
||||||
|
/// <summary>Posted (non-cancelled) expenses dated within [From, To], plus the all-time unpaid/overdue position.</summary>
|
||||||
|
public sealed record ExpenseSummaryReportDto(
|
||||||
|
DateOnly From, DateOnly To, decimal TotalAmount, decimal PaidAmount, decimal UnpaidAmount, int Count,
|
||||||
|
decimal OutstandingAmount, decimal OverdueAmount, int OverdueCount, int DraftCount,
|
||||||
|
IReadOnlyList<ExpenseCategoryTotalDto> Categories);
|
||||||
@@ -2,6 +2,14 @@ using ERPCore.Domain.Enums;
|
|||||||
|
|
||||||
namespace ERPCore.Dtos.Items;
|
namespace ERPCore.Dtos.Items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One posted GRN receipt of an item — what it was bought at. <paramref name="NetUnitCost"/> is the
|
||||||
|
/// cost after trade discount (the FIFO stock cost, VAT excluded) — the figure to price against.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ItemPurchasePriceDto(
|
||||||
|
int GrnId, string GrnDocNo, DateTime ReceivedAt, int VendorId, string? VendorName, string? WarehouseCode,
|
||||||
|
decimal Qty, decimal UnitCost, decimal DiscountPct, decimal NetUnitCost, decimal VatPct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
|
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
|
||||||
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
|
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Products;
|
||||||
|
|
||||||
|
/// <summary>One item's requested change. Current* are the item's prices now — they differ from Old* when it was repriced since.</summary>
|
||||||
|
public sealed record PriceAdjustmentLineDto(
|
||||||
|
int PriceAdjustmentLineId, int ItemId, string Sku, string ItemName,
|
||||||
|
decimal? OldMinPrice, decimal? OldMaxPrice, decimal? OldFlatPrice,
|
||||||
|
decimal? NewMinPrice, decimal? NewMaxPrice, decimal? NewFlatPrice,
|
||||||
|
decimal? CurrentMinPrice, decimal? CurrentMaxPrice, decimal? CurrentFlatPrice);
|
||||||
|
|
||||||
|
public sealed record PriceAdjustmentSummaryDto(
|
||||||
|
int PriceAdjustmentId, string DocNo, string Reason, PriceAdjustmentStatus Status, int RequiredApprovals, int? PendingLevel,
|
||||||
|
int LineCount, int CreatedBy, string? CreatedByName, DateTime CreatedAt, DateTime? AppliedAt);
|
||||||
|
|
||||||
|
/// <param name="PendingLevel">The approval it waits for (1 or 2); null once decided.</param>
|
||||||
|
public sealed record PriceAdjustmentDto(
|
||||||
|
int PriceAdjustmentId, string DocNo, string Reason, PriceAdjustmentStatus Status, int RequiredApprovals, int? PendingLevel,
|
||||||
|
int CreatedBy, string? CreatedByName, DateTime CreatedAt,
|
||||||
|
int? ApprovedLevel1By, DateTime? ApprovedLevel1At, int? ApprovedLevel2By, DateTime? ApprovedLevel2At,
|
||||||
|
int? RejectedBy, DateTime? RejectedAt, string? RejectReason, DateTime? CancelledAt, DateTime? AppliedAt,
|
||||||
|
IReadOnlyList<PriceAdjustmentLineDto> Lines, IReadOnlyList<ApprovalStepDto> ApprovalHistory, bool CanApprove, string? WhyNot,
|
||||||
|
bool CanWithdraw);
|
||||||
|
|
||||||
|
public sealed class PriceAdjustmentLineInput
|
||||||
|
{
|
||||||
|
[Required] public int ItemId { get; set; }
|
||||||
|
/// <summary>The prices the item should have afterwards — null clears that price.</summary>
|
||||||
|
[Range(0, double.MaxValue)] public decimal? NewMinPrice { get; set; }
|
||||||
|
[Range(0, double.MaxValue)] public decimal? NewMaxPrice { get; set; }
|
||||||
|
[Range(0, double.MaxValue)] public decimal? NewFlatPrice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreatePriceAdjustmentRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
|
||||||
|
[Required, MinLength(1)] public List<PriceAdjustmentLineInput> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ItemPriceHistoryDto(
|
||||||
|
int ItemPriceHistoryId, int ItemId, int? PriceAdjustmentId, string? DocNo,
|
||||||
|
decimal? OldMinPrice, decimal? OldMaxPrice, decimal? OldFlatPrice,
|
||||||
|
decimal? NewMinPrice, decimal? NewMaxPrice, decimal? NewFlatPrice,
|
||||||
|
int RequestedBy, string? RequestedByName, int? ApprovedLevel1By, string? ApprovedLevel1ByName,
|
||||||
|
int? ApprovedLevel2By, string? ApprovedLevel2ByName, DateTime AppliedAt);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
namespace ERPCore.Dtos.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One business day's figures (or, with <see cref="Date"/> null, a range's totals) — docs/DAY_END_SALES_REPORT_PLAN.md §4.
|
||||||
|
/// Sales are Posted slips, bundle sales and invoices dated that day; Paid is what has been paid on
|
||||||
|
/// those sales so far (split by how it was paid) and Due the rest.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Income">TotalSales − ExpenseTotal (sales returns aren't priced, so they can't be netted off).</param>
|
||||||
|
/// <param name="CostOfGoodsSold">FIFO cost of the stock those sales issued (the stock ledger's Out value).</param>
|
||||||
|
/// <param name="GrossProfit">TotalSales − CostOfGoodsSold.</param>
|
||||||
|
/// <param name="NetProfit">GrossProfit − ExpenseTotal; negative is a loss.</param>
|
||||||
|
/// <param name="EarlierInvoiceCollections">Invoice payments received this day against invoices from
|
||||||
|
/// earlier days — not part of PaidAmount.</param>
|
||||||
|
/// <param name="NetCash">Cash that came in this day (slip/bundle cash + cash invoice payments, any
|
||||||
|
/// invoice) minus expenses paid in cash this day — a till check.</param>
|
||||||
|
/// <param name="OpeningCashInHand">Cash left in the till from earlier days (docs/BANK_DEPOSIT_MODULE_PLAN.md §9).
|
||||||
|
/// For a range's totals, the first day's opening.</param>
|
||||||
|
/// <param name="CashDeposited">Posted bank deposits made against this business day.</param>
|
||||||
|
/// <param name="ClosingCashInHand">Opening + NetCash − CashDeposited: cash still in hand. For a range's totals, the last day's closing.</param>
|
||||||
|
public sealed record DayEndReportDayDto(
|
||||||
|
DateOnly? Date,
|
||||||
|
decimal TotalSales, decimal PaidAmount, decimal DueAmount,
|
||||||
|
decimal PaidByCash, decimal PaidByCard, decimal PaidByOther,
|
||||||
|
int SalesCount, int CashSalesCount, int CardSalesCount, int MixedSalesCount, int OtherSalesCount, int UnpaidSalesCount,
|
||||||
|
decimal ExpenseTotal, decimal Income,
|
||||||
|
decimal CostOfGoodsSold, decimal GrossProfit, decimal NetProfit,
|
||||||
|
decimal EarlierInvoiceCollections,
|
||||||
|
decimal NetCash,
|
||||||
|
decimal OpeningCashInHand, decimal CashDeposited, decimal ClosingCashInHand);
|
||||||
|
|
||||||
|
/// <summary>One sale of the day. <paramref name="PaidBy"/> is Cash, Card, Bank transfer, Cheque, Mixed or Unpaid.</summary>
|
||||||
|
public sealed record DayEndReportSaleDto(
|
||||||
|
string DocType, int DocId, string DocNo, DateTime At, string CustomerName,
|
||||||
|
decimal Total, decimal Paid, decimal Due, string PaidBy, decimal Cost, decimal GrossProfit);
|
||||||
|
|
||||||
|
public sealed record DayEndReportExpenseDto(
|
||||||
|
int ExpenseId, string DocNo, string CategoryName, string TitleName, string? Payee, decimal Amount, decimal PaidAmount);
|
||||||
|
|
||||||
|
/// <summary>A Posted bank deposit of this business day's cash.</summary>
|
||||||
|
public sealed record DayEndReportDepositDto(
|
||||||
|
int BankDepositId, string DocNo, string BankAccountName, string? BankName, string? AccountNumber,
|
||||||
|
string? DepositSlipNo, DateOnly DepositDate, decimal Amount);
|
||||||
|
|
||||||
|
/// <summary>A single day: its figures plus the sales, expenses and bank deposits behind them.</summary>
|
||||||
|
public sealed record DayEndReportDto(
|
||||||
|
DayEndReportDayDto Summary, IReadOnlyList<DayEndReportSaleDto> Sales, IReadOnlyList<DayEndReportExpenseDto> Expenses,
|
||||||
|
IReadOnlyList<DayEndReportDepositDto> Deposits, int? BranchId, string TimeZone);
|
||||||
|
|
||||||
|
/// <summary>Day-wise figures for a range (every day present, zeros on quiet days) plus the range's totals.</summary>
|
||||||
|
public sealed record DayEndDailyReportDto(
|
||||||
|
DateOnly From, DateOnly To, IReadOnlyList<DayEndReportDayDto> Days, DayEndReportDayDto Totals, int? BranchId, string TimeZone);
|
||||||
@@ -12,7 +12,7 @@ public sealed record BundleSaleDto(
|
|||||||
int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode,
|
int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode,
|
||||||
BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount,
|
BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount,
|
||||||
decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt,
|
decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
IReadOnlyList<BundleSaleLineDto> Lines);
|
IReadOnlyList<BundleSaleLineDto> Lines, decimal CashAmount, decimal CardAmount);
|
||||||
|
|
||||||
public sealed record BundleSaleSummaryDto(
|
public sealed record BundleSaleSummaryDto(
|
||||||
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
|
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
|
||||||
@@ -66,6 +66,8 @@ public sealed class CreateBundleSaleRequest
|
|||||||
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
||||||
public bool AllowPriceOverride { get; set; }
|
public bool AllowPriceOverride { get; set; }
|
||||||
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||||
|
/// <summary>The part of the total tendered by card; the rest is cash. Omit for all cash.</summary>
|
||||||
|
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateBundleSaleRequest
|
public sealed class UpdateBundleSaleRequest
|
||||||
@@ -78,4 +80,6 @@ public sealed class UpdateBundleSaleRequest
|
|||||||
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
||||||
public bool AllowPriceOverride { get; set; }
|
public bool AllowPriceOverride { get; set; }
|
||||||
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||||
|
/// <summary>See <see cref="CreateBundleSaleRequest.CardAmount"/>.</summary>
|
||||||
|
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ public sealed record SalesInvoiceSummaryDto(
|
|||||||
|
|
||||||
public sealed record SalesInvoicePaymentDto(
|
public sealed record SalesInvoicePaymentDto(
|
||||||
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
|
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt,
|
||||||
|
SalesPaymentMethod PaymentMethod);
|
||||||
|
|
||||||
public sealed class CreateSalesInvoicePaymentRequest
|
public sealed class CreateSalesInvoicePaymentRequest
|
||||||
{
|
{
|
||||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||||
[Required] public long GlBankAccountId { get; set; }
|
[Required] public long GlBankAccountId { get; set; }
|
||||||
|
/// <summary>How the customer paid. Omitted ⇒ derived from the account: a Cash account ⇒ Cash, a Bank account ⇒ Card.</summary>
|
||||||
|
public SalesPaymentMethod? PaymentMethod { get; set; }
|
||||||
[StringLength(100)] public string? Reference { get; set; }
|
[StringLength(100)] public string? Reference { get; set; }
|
||||||
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||||
public DateOnly? PaymentDate { get; set; }
|
public DateOnly? PaymentDate { get; set; }
|
||||||
@@ -47,6 +50,8 @@ public sealed class BulkSalesInvoicePaymentLine
|
|||||||
{
|
{
|
||||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||||
[Required] public long GlBankAccountId { get; set; }
|
[Required] public long GlBankAccountId { get; set; }
|
||||||
|
/// <summary>See <see cref="CreateSalesInvoicePaymentRequest.PaymentMethod"/>.</summary>
|
||||||
|
public SalesPaymentMethod? PaymentMethod { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed record SalesSlipLineDto(
|
|||||||
|
|
||||||
public sealed record SalesSlipTotalsDto(
|
public sealed record SalesSlipTotalsDto(
|
||||||
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
||||||
decimal PaidAmount, decimal BalanceAmount);
|
decimal PaidAmount, decimal BalanceAmount, decimal CashAmount, decimal CardAmount);
|
||||||
|
|
||||||
public sealed record SalesSlipDto(
|
public sealed record SalesSlipDto(
|
||||||
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
||||||
@@ -87,6 +87,10 @@ public sealed class CreateSalesSlipRequest
|
|||||||
/// Receivable when it posts (see SalesDayEndService.CloseAsync).
|
/// Receivable when it posts (see SalesDayEndService.CloseAsync).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
|
[Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The part of the paid amount tendered by card; the rest of it is cash. Omit for all
|
||||||
|
/// cash. Must be between 0 and the paid amount.</summary>
|
||||||
|
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateSalesSlipRequest
|
public sealed class UpdateSalesSlipRequest
|
||||||
@@ -98,4 +102,7 @@ public sealed class UpdateSalesSlipRequest
|
|||||||
|
|
||||||
/// <summary>See <see cref="CreateSalesSlipRequest.PaidAmount"/>.</summary>
|
/// <summary>See <see cref="CreateSalesSlipRequest.PaidAmount"/>.</summary>
|
||||||
[Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
|
[Range(0, double.MaxValue)] public decimal? PaidAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>See <see cref="CreateSalesSlipRequest.CardAmount"/>.</summary>
|
||||||
|
[Range(0, double.MaxValue)] public decimal? CardAmount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,29 @@ namespace ERPCore.Dtos.Stock;
|
|||||||
|
|
||||||
// Responses (docs/11 §5.5) ------------------------------------------------------
|
// Responses (docs/11 §5.5) ------------------------------------------------------
|
||||||
|
|
||||||
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId);
|
/// <param name="RequestedSerialIds">Serials named to write off (shown while the request is pending).</param>
|
||||||
|
/// <param name="RequestedSerialNos">Serial numbers named as found (shown while the request is pending).</param>
|
||||||
|
public sealed record AdjustmentLineDto(
|
||||||
|
int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId,
|
||||||
|
IReadOnlyList<int>? RequestedSerialIds = null, IReadOnlyList<string>? RequestedSerialNos = null);
|
||||||
|
|
||||||
|
/// <param name="PendingLevel">The approval it waits for (1 or 2); null once decided or posted.</param>
|
||||||
|
/// <param name="CanApprove">Whether the current user may give that approval; <paramref name="WhyNot"/> says why not.</param>
|
||||||
public sealed record AdjustmentDto(
|
public sealed record AdjustmentDto(
|
||||||
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
|
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs,
|
||||||
|
int RequiredApprovals = 0, int? PendingLevel = null,
|
||||||
|
int? ApprovedLevel1By = null, DateTime? ApprovedLevel1At = null, int? ApprovedLevel2By = null, DateTime? ApprovedLevel2At = null,
|
||||||
|
int? RejectedBy = null, DateTime? RejectedAt = null, string? RejectReason = null, DateTime? PostedAt = null,
|
||||||
|
IReadOnlyList<Approvals.ApprovalStepDto>? ApprovalHistory = null, bool CanApprove = false, string? WhyNot = null,
|
||||||
|
bool CanWithdraw = false, string? CreatedByName = null);
|
||||||
|
|
||||||
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||||
public sealed record AdjustmentSummaryDto(
|
public sealed record AdjustmentSummaryDto(
|
||||||
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
|
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo,
|
||||||
|
int RequiredApprovals = 0, int? PendingLevel = null);
|
||||||
|
|
||||||
// Requests ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ public sealed record CountSummaryDto(
|
|||||||
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
|
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||||
|
|
||||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
|
/// <param name="AdjustmentStatus">The variance adjustment's status — <c>PendingApproval</c> when it must be approved before stock moves.</param>
|
||||||
|
public sealed record CountPostResultDto(
|
||||||
|
int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs,
|
||||||
|
AdjustmentStatus? AdjustmentStatus = null, string? AdjustmentDocNo = null);
|
||||||
|
|
||||||
// Requests ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using ERPCore.Services.Stock;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After every API action that succeeded, checks whether its stock issues pushed anything to or
|
||||||
|
/// below its reorder point (<see cref="LowStockAlertService"/>). By then the action's UoW
|
||||||
|
/// transaction has committed, so only stock that really left is alerted on. The check is
|
||||||
|
/// best-effort: a failure is logged and never fails the request that moved the stock.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LowStockAlertFilter(LowStockAlertService alerts, LowStockTracker tracker, ILogger<LowStockAlertFilter> logger) : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var executed = await next();
|
||||||
|
|
||||||
|
// A failed action rolled its transaction back: nothing it "issued" actually left stock.
|
||||||
|
if (executed.Exception is not null && !executed.ExceptionHandled)
|
||||||
|
{
|
||||||
|
tracker.Drain();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await alerts.CheckAsync(context.HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Low-stock check failed after {Path}", context.HttpContext.Request.Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>Dual approval tables (docs/DUAL_APPROVAL_PLAN.md §4, §6, §7).</summary>
|
||||||
|
public sealed class DocumentApprovalConfiguration : IEntityTypeConfiguration<DocumentApproval>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<DocumentApproval> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("document_approvals");
|
||||||
|
builder.HasKey(a => a.DocumentApprovalId);
|
||||||
|
|
||||||
|
builder.Property(a => a.DocType).IsRequired().HasMaxLength(10);
|
||||||
|
builder.Property(a => a.Decision).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(a => a.Comment).HasMaxLength(250);
|
||||||
|
builder.Property(a => a.DecidedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(a => a.Decider).WithMany().HasForeignKey(a => a.DecidedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasIndex(a => new { a.DocType, a.DocId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<ApprovalConfig>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ApprovalConfig> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("approval_config", t =>
|
||||||
|
t.HasCheckConstraint("ck_approval_config_singleton", $"`ApprovalConfigId` = {ApprovalConfig.SingletonId}"));
|
||||||
|
builder.HasKey(c => c.ApprovalConfigId);
|
||||||
|
builder.Property(c => c.ApprovalConfigId).ValueGeneratedNever();
|
||||||
|
|
||||||
|
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
|
||||||
|
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
|
||||||
|
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500);
|
||||||
|
|
||||||
|
// Dual approval on by default for both documents (plan §8 recommended defaults).
|
||||||
|
builder.HasData(new ApprovalConfig
|
||||||
|
{
|
||||||
|
ApprovalConfigId = ApprovalConfig.SingletonId,
|
||||||
|
StockAdjustmentLevels = 2,
|
||||||
|
PriceAdjustmentLevels = 2,
|
||||||
|
RowVersion = 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PriceAdjustmentConfiguration : IEntityTypeConfiguration<PriceAdjustment>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PriceAdjustment> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("price_adjustments");
|
||||||
|
builder.HasKey(p => p.PriceAdjustmentId);
|
||||||
|
|
||||||
|
builder.Property(p => p.DocNo).IsRequired().HasMaxLength(30);
|
||||||
|
builder.HasIndex(p => p.DocNo).IsUnique();
|
||||||
|
builder.Property(p => p.Reason).IsRequired().HasMaxLength(250);
|
||||||
|
builder.Property(p => p.Status).HasConversion<string>().HasMaxLength(30).IsRequired()
|
||||||
|
.HasDefaultValue(PriceAdjustmentStatus.PendingApproval);
|
||||||
|
builder.Property(p => p.RejectReason).HasMaxLength(250);
|
||||||
|
builder.Property(p => p.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasMany(p => p.Lines).WithOne(l => l.PriceAdjustment).HasForeignKey(l => l.PriceAdjustmentId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasIndex(p => p.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PriceAdjustmentLineConfiguration : IEntityTypeConfiguration<PriceAdjustmentLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PriceAdjustmentLine> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("price_adjustment_lines");
|
||||||
|
builder.HasKey(l => l.PriceAdjustmentLineId);
|
||||||
|
foreach (var p in new[] { nameof(PriceAdjustmentLine.OldMinPrice), nameof(PriceAdjustmentLine.OldMaxPrice), nameof(PriceAdjustmentLine.OldFlatPrice),
|
||||||
|
nameof(PriceAdjustmentLine.NewMinPrice), nameof(PriceAdjustmentLine.NewMaxPrice), nameof(PriceAdjustmentLine.NewFlatPrice) })
|
||||||
|
builder.Property<decimal?>(p).HasPrecision(18, 4);
|
||||||
|
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasIndex(l => l.ItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ItemPriceHistoryConfiguration : IEntityTypeConfiguration<ItemPriceHistory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ItemPriceHistory> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("item_price_history");
|
||||||
|
builder.HasKey(h => h.ItemPriceHistoryId);
|
||||||
|
foreach (var p in new[] { nameof(ItemPriceHistory.OldMinPrice), nameof(ItemPriceHistory.OldMaxPrice), nameof(ItemPriceHistory.OldFlatPrice),
|
||||||
|
nameof(ItemPriceHistory.NewMinPrice), nameof(ItemPriceHistory.NewMaxPrice), nameof(ItemPriceHistory.NewFlatPrice) })
|
||||||
|
builder.Property<decimal?>(p).HasPrecision(18, 4);
|
||||||
|
builder.Property(h => h.AppliedAt).IsRequired();
|
||||||
|
builder.HasOne(h => h.Item).WithMany().HasForeignKey(h => h.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(h => h.PriceAdjustment).WithMany().HasForeignKey(h => h.PriceAdjustmentId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasIndex(h => new { h.ItemId, h.AppliedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>Bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md §4).</summary>
|
||||||
|
public sealed class BankDepositConfiguration : IEntityTypeConfiguration<BankDeposit>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BankDeposit> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("bank_deposits", t =>
|
||||||
|
t.HasCheckConstraint("ck_bank_deposits_amount", "`Amount` > 0"));
|
||||||
|
builder.HasKey(x => x.BankDepositId);
|
||||||
|
|
||||||
|
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
|
||||||
|
builder.HasIndex(x => x.DocNo).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(x => x.BusinessDate).IsRequired();
|
||||||
|
builder.Property(x => x.DepositDate).IsRequired();
|
||||||
|
builder.Property(x => x.Amount).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(x => x.FromCashAccountCode).IsRequired().HasMaxLength(20);
|
||||||
|
builder.Property(x => x.FromCashAccountName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(x => x.ToBankAccountCode).IsRequired().HasMaxLength(20);
|
||||||
|
builder.Property(x => x.ToBankAccountName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(x => x.ToBankName).HasMaxLength(150);
|
||||||
|
builder.Property(x => x.ToAccountNumber).HasMaxLength(50);
|
||||||
|
|
||||||
|
builder.Property(x => x.DepositSlipNo).HasMaxLength(50);
|
||||||
|
builder.Property(x => x.DepositedBy).HasMaxLength(150);
|
||||||
|
builder.Property(x => x.Remarks).HasMaxLength(500);
|
||||||
|
|
||||||
|
builder.Property(x => x.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(BankDepositStatus.Posted);
|
||||||
|
|
||||||
|
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||||
|
builder.Property(x => x.GlReversalJournalNo).HasMaxLength(30);
|
||||||
|
builder.Property(x => x.CancelReason).HasMaxLength(250);
|
||||||
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasIndex(x => new { x.BranchId, x.BusinessDate, x.Status });
|
||||||
|
builder.HasIndex(x => x.ToGlBankAccountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSal
|
|||||||
builder.Property(x => x.DiscountTotal).HasPrecision(18, 4);
|
builder.Property(x => x.DiscountTotal).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
|
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
|
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.CashAmount).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.CardAmount).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.CreatedAt).IsRequired();
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
|
|
||||||
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expense module tables (docs/EXPENSE_MODULE_PLAN.md §3). The four categories and their
|
||||||
|
/// titles are seeded as <c>IsSystem</c> rows. The GL codes are the children of the GL
|
||||||
|
/// "Operating Expenses" (5200) control account created for this module on 2026-10-08 — GL
|
||||||
|
/// generates codes itself, so a GL instance with a different chart needs these corrected per
|
||||||
|
/// title on the Expense Setup screen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExpenseCategoryConfiguration : IEntityTypeConfiguration<ExpenseCategory>
|
||||||
|
{
|
||||||
|
public const string UtilityGlCode = "5200-01";
|
||||||
|
public const string RentalGlCode = "5200-02";
|
||||||
|
public const string SalariesGlCode = "5200-03";
|
||||||
|
public const string OtherGlCode = "5200-04";
|
||||||
|
|
||||||
|
public void Configure(EntityTypeBuilder<ExpenseCategory> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("expense_categories");
|
||||||
|
builder.HasKey(c => c.ExpenseCategoryId);
|
||||||
|
|
||||||
|
builder.Property(c => c.Code).IsRequired().HasMaxLength(30);
|
||||||
|
builder.HasIndex(c => c.Code).IsUnique();
|
||||||
|
builder.Property(c => c.Name).IsRequired().HasMaxLength(100);
|
||||||
|
builder.HasIndex(c => c.Name).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(c => c.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
builder.Property(c => c.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasMany(c => c.Titles)
|
||||||
|
.WithOne(t => t.Category)
|
||||||
|
.HasForeignKey(t => t.ExpenseCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
var seededAt = new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
builder.HasData(
|
||||||
|
new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility", IsSystem = true, SortOrder = 1, CreatedAt = seededAt, RowVersion = 1 },
|
||||||
|
new ExpenseCategory { ExpenseCategoryId = 2, Code = "RENTAL", Name = "Rental", IsSystem = true, SortOrder = 2, CreatedAt = seededAt, RowVersion = 1 },
|
||||||
|
new ExpenseCategory { ExpenseCategoryId = 3, Code = "SALARIES", Name = "Salaries", IsSystem = true, SortOrder = 3, CreatedAt = seededAt, RowVersion = 1 },
|
||||||
|
new ExpenseCategory { ExpenseCategoryId = 4, Code = "OTHER", Name = "Other", IsSystem = true, SortOrder = 4, CreatedAt = seededAt, RowVersion = 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExpenseTitleConfiguration : IEntityTypeConfiguration<ExpenseTitle>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ExpenseTitle> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("expense_titles");
|
||||||
|
builder.HasKey(t => t.ExpenseTitleId);
|
||||||
|
|
||||||
|
builder.Property(t => t.Name).IsRequired().HasMaxLength(100);
|
||||||
|
builder.HasIndex(t => new { t.ExpenseCategoryId, t.Name }).IsUnique();
|
||||||
|
builder.Property(t => t.GlExpenseAccountCode).IsRequired().HasMaxLength(20);
|
||||||
|
|
||||||
|
builder.Property(t => t.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
builder.Property(t => t.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
var seededAt = new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var id = 0;
|
||||||
|
ExpenseTitle Seed(int categoryId, string name, string glCode, int sortOrder) => new()
|
||||||
|
{
|
||||||
|
ExpenseTitleId = ++id, ExpenseCategoryId = categoryId, Name = name, GlExpenseAccountCode = glCode,
|
||||||
|
IsSystem = true, SortOrder = sortOrder, CreatedAt = seededAt, RowVersion = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append only — the ids are assigned in this order, so reordering would renumber seeded rows.
|
||||||
|
builder.HasData(
|
||||||
|
Seed(1, "Electricity", ExpenseCategoryConfiguration.UtilityGlCode, 1),
|
||||||
|
Seed(1, "Water", ExpenseCategoryConfiguration.UtilityGlCode, 2),
|
||||||
|
Seed(1, "Telephone", ExpenseCategoryConfiguration.UtilityGlCode, 3),
|
||||||
|
Seed(1, "Internet", ExpenseCategoryConfiguration.UtilityGlCode, 4),
|
||||||
|
Seed(1, "Gas", ExpenseCategoryConfiguration.UtilityGlCode, 5),
|
||||||
|
Seed(2, "Building Rent", ExpenseCategoryConfiguration.RentalGlCode, 1),
|
||||||
|
Seed(2, "Vehicle Rent", ExpenseCategoryConfiguration.RentalGlCode, 2),
|
||||||
|
Seed(2, "Equipment Rent", ExpenseCategoryConfiguration.RentalGlCode, 3),
|
||||||
|
Seed(3, "Staff Salaries", ExpenseCategoryConfiguration.SalariesGlCode, 1),
|
||||||
|
Seed(3, "Wages", ExpenseCategoryConfiguration.SalariesGlCode, 2),
|
||||||
|
Seed(3, "Overtime", ExpenseCategoryConfiguration.SalariesGlCode, 3),
|
||||||
|
Seed(3, "Bonus", ExpenseCategoryConfiguration.SalariesGlCode, 4),
|
||||||
|
Seed(3, "EPF / ETF Contribution", ExpenseCategoryConfiguration.SalariesGlCode, 5),
|
||||||
|
Seed(4, "Stationery", ExpenseCategoryConfiguration.OtherGlCode, 1),
|
||||||
|
Seed(4, "Transport", ExpenseCategoryConfiguration.OtherGlCode, 2),
|
||||||
|
Seed(4, "Repairs & Maintenance", ExpenseCategoryConfiguration.OtherGlCode, 3),
|
||||||
|
Seed(4, "Cleaning", ExpenseCategoryConfiguration.OtherGlCode, 4),
|
||||||
|
Seed(4, "Miscellaneous", ExpenseCategoryConfiguration.OtherGlCode, 5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExpenseConfiguration : IEntityTypeConfiguration<Expense>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Expense> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("expenses", t =>
|
||||||
|
t.HasCheckConstraint("ck_expenses_amount", "`Amount` > 0"));
|
||||||
|
builder.HasKey(x => x.ExpenseId);
|
||||||
|
|
||||||
|
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
|
||||||
|
builder.HasIndex(x => x.DocNo).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(x => x.ExpenseDate).IsRequired();
|
||||||
|
builder.Property(x => x.Payee).HasMaxLength(150);
|
||||||
|
builder.Property(x => x.BillReference).HasMaxLength(50);
|
||||||
|
builder.Property(x => x.Description).HasMaxLength(500);
|
||||||
|
builder.Property(x => x.PeriodMonth).HasMaxLength(7);
|
||||||
|
|
||||||
|
foreach (var p in new[] { nameof(Expense.Amount), nameof(Expense.PaidAmount), nameof(Expense.BalanceAmount) })
|
||||||
|
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(x => x.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(ExpenseStatus.Draft);
|
||||||
|
builder.Property(x => x.PaymentStatus)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(ExpensePaymentStatus.Unpaid);
|
||||||
|
|
||||||
|
builder.Property(x => x.GlExpenseAccountCode).HasMaxLength(20);
|
||||||
|
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||||
|
builder.Property(x => x.GlReversalJournalNo).HasMaxLength(30);
|
||||||
|
builder.Property(x => x.CancelReason).HasMaxLength(250);
|
||||||
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Category).WithMany().HasForeignKey(x => x.ExpenseCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Title).WithMany().HasForeignKey(x => x.ExpenseTitleId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Vendor).WithMany().HasForeignKey(x => x.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasMany(x => x.Payments)
|
||||||
|
.WithOne(p => p.Expense)
|
||||||
|
.HasForeignKey(p => p.ExpenseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasIndex(x => x.Status);
|
||||||
|
builder.HasIndex(x => x.PaymentStatus);
|
||||||
|
builder.HasIndex(x => x.ExpenseDate);
|
||||||
|
builder.HasIndex(x => x.BranchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExpensePaymentConfiguration : IEntityTypeConfiguration<ExpensePayment>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ExpensePayment> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("expense_payments");
|
||||||
|
builder.HasKey(p => p.ExpensePaymentId);
|
||||||
|
|
||||||
|
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||||
|
builder.Property(p => p.PaymentDate).IsRequired();
|
||||||
|
builder.Property(p => p.PaymentMethod).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(p => p.ChequeNo).HasMaxLength(30);
|
||||||
|
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||||
|
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||||
|
builder.Property(p => p.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasIndex(p => p.ExpenseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,13 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
|||||||
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
|
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
|
||||||
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
|
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
|
||||||
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 },
|
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 },
|
||||||
new Permission { PermissionId = 53, Code = "NAV:products.tax-rates", SubNavItemId = 43 }
|
new Permission { PermissionId = 53, Code = "NAV:products.tax-rates", SubNavItemId = 43 },
|
||||||
|
new Permission { PermissionId = 54, Code = "NAV:accounts.expenses", SubNavItemId = 44 },
|
||||||
|
new Permission { PermissionId = 55, Code = "NAV:accounts.expense-setup", SubNavItemId = 45 },
|
||||||
|
new Permission { PermissionId = 56, Code = "NAV:sales.day-end-report", SubNavItemId = 46 },
|
||||||
|
new Permission { PermissionId = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 },
|
||||||
|
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments", SubNavItemId = 48 },
|
||||||
|
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ public sealed class SalesInvoicePaymentConfiguration : IEntityTypeConfiguration<
|
|||||||
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||||
builder.Property(p => p.PaymentDate).IsRequired();
|
builder.Property(p => p.PaymentDate).IsRequired();
|
||||||
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(p => p.PaymentMethod)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(SalesPaymentMethod.Cash);
|
||||||
builder.Property(p => p.Reference).HasMaxLength(100);
|
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||||
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||||
builder.Property(p => p.CreatedAt).IsRequired();
|
builder.Property(p => p.CreatedAt).IsRequired();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public sealed class SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
|
|||||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
.HasDefaultValue(SalesSlipStatus.Draft);
|
.HasDefaultValue(SalesSlipStatus.Draft);
|
||||||
|
|
||||||
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) })
|
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount), nameof(SalesSlip.CashAmount), nameof(SalesSlip.CardAmount) })
|
||||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||||
|
|
||||||
builder.Property(x => x.CreatedAt).IsRequired();
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
|
|||||||
builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30);
|
builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30);
|
||||||
builder.HasIndex(a => a.DocNo).IsUnique();
|
builder.HasIndex(a => a.DocNo).IsUnique();
|
||||||
|
|
||||||
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
// 30, not 20: "PendingSecondApproval" is 21 characters.
|
||||||
|
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(30).IsRequired();
|
||||||
builder.Property(a => a.CreatedAt).IsRequired();
|
builder.Property(a => a.CreatedAt).IsRequired();
|
||||||
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
|
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
|
||||||
|
builder.Property(a => a.RejectReason).HasMaxLength(250);
|
||||||
|
builder.HasIndex(a => a.Status);
|
||||||
|
|
||||||
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||||
@@ -35,6 +38,8 @@ public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<
|
|||||||
builder.HasKey(l => l.AdjLineId);
|
builder.HasKey(l => l.AdjLineId);
|
||||||
|
|
||||||
builder.Property(l => l.QtyDelta).HasPrecision(18, 4);
|
builder.Property(l => l.QtyDelta).HasPrecision(18, 4);
|
||||||
|
builder.Property(l => l.RequestedSerialIds).HasMaxLength(4000);
|
||||||
|
builder.Property(l => l.RequestedSerialNos).HasMaxLength(4000);
|
||||||
|
|
||||||
builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade);
|
builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade);
|
||||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|||||||
@@ -32,17 +32,20 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
|||||||
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
|
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
|
||||||
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
||||||
new SubNavItem { SubNavItemId = 43, NavItemId = 2, Code = "products.tax-rates", Label = "Tax Rates", Href = "/dashboard/products/tax-rates", SortOrder = 7 },
|
new SubNavItem { SubNavItemId = 43, NavItemId = 2, Code = "products.tax-rates", Label = "Tax Rates", Href = "/dashboard/products/tax-rates", SortOrder = 7 },
|
||||||
|
new SubNavItem { SubNavItemId = 48, NavItemId = 2, Code = "products.price-adjustments", Label = "Price Adjustments", Href = "/dashboard/products/price-adjustments", SortOrder = 8 },
|
||||||
new SubNavItem { SubNavItemId = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 },
|
new SubNavItem { SubNavItemId = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 },
|
||||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
|
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
|
||||||
|
new SubNavItem { SubNavItemId = 49, NavItemId = 9, Code = "settings.approvals", Label = "Approvals", Href = "/dashboard/settings/approvals", SortOrder = 4 },
|
||||||
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
|
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
|
||||||
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
|
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
|
||||||
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
|
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
|
||||||
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
|
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
|
||||||
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
|
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Approved Invoices", Href = "/dashboard/sales/day-end", SortOrder = 4 },
|
||||||
|
new SubNavItem { SubNavItemId = 46, NavItemId = 13, Code = "sales.day-end-report", Label = "Day-End Report", Href = "/dashboard/sales/day-end-report", SortOrder = 5 },
|
||||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||||
// these procurement rows were never actually migrated into the database before now.
|
// these procurement rows were never actually migrated into the database before now.
|
||||||
@@ -67,6 +70,9 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
|||||||
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
|
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
|
||||||
|
new SubNavItem { SubNavItemId = 44, NavItemId = 12, Code = "accounts.expenses", Label = "Expenses", Href = "/dashboard/accounts/expenses", SortOrder = 4 },
|
||||||
|
new SubNavItem { SubNavItemId = 45, NavItemId = 12, Code = "accounts.expense-setup", Label = "Expense Setup", Href = "/dashboard/accounts/expense-setup", SortOrder = 5 },
|
||||||
|
new SubNavItem { SubNavItemId = 47, NavItemId = 12, Code = "accounts.bank-deposits", Label = "Bank Deposits", Href = "/dashboard/accounts/bank-deposits", SortOrder = 6 },
|
||||||
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
|
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
|
||||||
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
|
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
|
||||||
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
|
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
|
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
|
||||||
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
|
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
|
||||||
|
|
||||||
|
// --- Accounts: expenses (docs/EXPENSE_MODULE_PLAN.md) ---
|
||||||
|
public DbSet<ExpenseCategory> ExpenseCategories => Set<ExpenseCategory>();
|
||||||
|
public DbSet<ExpenseTitle> ExpenseTitles => Set<ExpenseTitle>();
|
||||||
|
public DbSet<Expense> Expenses => Set<Expense>();
|
||||||
|
public DbSet<ExpensePayment> ExpensePayments => Set<ExpensePayment>();
|
||||||
|
|
||||||
|
// --- Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md) ---
|
||||||
|
public DbSet<BankDeposit> BankDeposits => Set<BankDeposit>();
|
||||||
|
|
||||||
|
// --- Dual approval: stock & price adjustments (docs/DUAL_APPROVAL_PLAN.md) ---
|
||||||
|
public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>();
|
||||||
|
public DbSet<ApprovalConfig> ApprovalConfig => Set<ApprovalConfig>();
|
||||||
|
public DbSet<PriceAdjustment> PriceAdjustments => Set<PriceAdjustment>();
|
||||||
|
public DbSet<PriceAdjustmentLine> PriceAdjustmentLines => Set<PriceAdjustmentLine>();
|
||||||
|
public DbSet<ItemPriceHistory> ItemPriceHistory => Set<ItemPriceHistory>();
|
||||||
|
|
||||||
// --- Batch / Serial (docs/10 Part C.4) ---
|
// --- Batch / Serial (docs/10 Part C.4) ---
|
||||||
public DbSet<Batch> Batches => Set<Batch>();
|
public DbSet<Batch> Batches => Set<Batch>();
|
||||||
public DbSet<Serial> Serials => Set<Serial>();
|
public DbSet<Serial> Serials => Set<Serial>();
|
||||||
@@ -221,6 +237,8 @@ public class ErpDbContext : DbContext
|
|||||||
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||||
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||||
|
modelBuilder.Entity<Expense>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||||
|
modelBuilder.Entity<BankDeposit>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||||
|
|
||||||
// Optimistic concurrency token. On PostgreSQL this was the virtual `xmin` system
|
// Optimistic concurrency token. On PostgreSQL this was the virtual `xmin` system
|
||||||
// column via .IsRowVersion(), which the engine maintained for free. MariaDB has no
|
// column via .IsRowVersion(), which the engine maintained for free. MariaDB has no
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExpenseModule : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "expense_categories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExpenseCategoryId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
IsSystem = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Active", collation: "utf8mb4_unicode_ci"),
|
||||||
|
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_expense_categories", x => x.ExpenseCategoryId);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "expense_titles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExpenseTitleId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
ExpenseCategoryId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlExpenseAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
IsSystem = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Active", collation: "utf8mb4_unicode_ci"),
|
||||||
|
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_expense_titles", x => x.ExpenseTitleId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expense_titles_expense_categories_ExpenseCategoryId",
|
||||||
|
column: x => x.ExpenseCategoryId,
|
||||||
|
principalTable: "expense_categories",
|
||||||
|
principalColumn: "ExpenseCategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "expenses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExpenseId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
DocNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
BranchId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ExpenseDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
DueDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
ExpenseCategoryId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ExpenseTitleId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Payee = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
VendorId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
BillReference = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
PeriodMonth = table.Column<string>(type: "varchar(7)", maxLength: 7, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
PaidAmount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
BalanceAmount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Draft", collation: "utf8mb4_unicode_ci"),
|
||||||
|
PaymentStatus = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Unpaid", collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlExpenseAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlPostedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
GlReversalJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CancelReason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CreatedBy = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_expenses", x => x.ExpenseId);
|
||||||
|
table.CheckConstraint("ck_expenses_amount", "`Amount` > 0");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expenses_expense_categories_ExpenseCategoryId",
|
||||||
|
column: x => x.ExpenseCategoryId,
|
||||||
|
principalTable: "expense_categories",
|
||||||
|
principalColumn: "ExpenseCategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expenses_expense_titles_ExpenseTitleId",
|
||||||
|
column: x => x.ExpenseTitleId,
|
||||||
|
principalTable: "expense_titles",
|
||||||
|
principalColumn: "ExpenseTitleId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expenses_hr_branches_BranchId",
|
||||||
|
column: x => x.BranchId,
|
||||||
|
principalTable: "hr_branches",
|
||||||
|
principalColumn: "BranchId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expenses_users_CreatedBy",
|
||||||
|
column: x => x.CreatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expenses_vendors_VendorId",
|
||||||
|
column: x => x.VendorId,
|
||||||
|
principalTable: "vendors",
|
||||||
|
principalColumn: "VendorId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "expense_payments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExpensePaymentId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
ExpenseId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
PaymentDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
PaymentMethod = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
BankAccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
ChequeNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Reference = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CreatedBy = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_expense_payments", x => x.ExpensePaymentId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expense_payments_expenses_ExpenseId",
|
||||||
|
column: x => x.ExpenseId,
|
||||||
|
principalTable: "expenses",
|
||||||
|
principalColumn: "ExpenseId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_expense_payments_users_CreatedBy",
|
||||||
|
column: x => x.CreatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "expense_categories",
|
||||||
|
columns: new[] { "ExpenseCategoryId", "Code", "CreatedAt", "IsSystem", "Name", "RowVersion", "SortOrder", "UpdatedAt" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "UTILITY", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Utility", 1u, 1, null },
|
||||||
|
{ 2, "RENTAL", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Rental", 1u, 2, null },
|
||||||
|
{ 3, "SALARIES", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Salaries", 1u, 3, null },
|
||||||
|
{ 4, "OTHER", new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), true, "Other", 1u, 4, null }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 44, "accounts.expenses", "/dashboard/accounts/expenses", null, "Expenses", 12, 4 },
|
||||||
|
{ 45, "accounts.expense-setup", "/dashboard/accounts/expense-setup", null, "Expense Setup", 12, 5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "expense_titles",
|
||||||
|
columns: new[] { "ExpenseTitleId", "CreatedAt", "ExpenseCategoryId", "GlExpenseAccountCode", "IsSystem", "Name", "RowVersion", "SortOrder", "UpdatedAt" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Electricity", 1u, 1, null },
|
||||||
|
{ 2, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Water", 1u, 2, null },
|
||||||
|
{ 3, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Telephone", 1u, 3, null },
|
||||||
|
{ 4, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Internet", 1u, 4, null },
|
||||||
|
{ 5, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 1, "6100", true, "Gas", 1u, 5, null },
|
||||||
|
{ 6, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 2, "6200", true, "Building Rent", 1u, 1, null },
|
||||||
|
{ 7, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 2, "6200", true, "Vehicle Rent", 1u, 2, null },
|
||||||
|
{ 8, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 2, "6200", true, "Equipment Rent", 1u, 3, null },
|
||||||
|
{ 9, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Staff Salaries", 1u, 1, null },
|
||||||
|
{ 10, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Wages", 1u, 2, null },
|
||||||
|
{ 11, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Overtime", 1u, 3, null },
|
||||||
|
{ 12, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "Bonus", 1u, 4, null },
|
||||||
|
{ 13, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 3, "6300", true, "EPF / ETF Contribution", 1u, 5, null },
|
||||||
|
{ 14, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Stationery", 1u, 1, null },
|
||||||
|
{ 15, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Transport", 1u, 2, null },
|
||||||
|
{ 16, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Repairs & Maintenance", 1u, 3, null },
|
||||||
|
{ 17, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Cleaning", 1u, 4, null },
|
||||||
|
{ 18, new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), 4, "6900", true, "Miscellaneous", 1u, 5, null }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 54, "NAV:accounts.expenses", null, 44 },
|
||||||
|
{ 55, "NAV:accounts.expense-setup", null, 45 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expense_categories_Code",
|
||||||
|
table: "expense_categories",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expense_categories_Name",
|
||||||
|
table: "expense_categories",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expense_payments_CreatedBy",
|
||||||
|
table: "expense_payments",
|
||||||
|
column: "CreatedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expense_payments_ExpenseId",
|
||||||
|
table: "expense_payments",
|
||||||
|
column: "ExpenseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expense_titles_ExpenseCategoryId_Name",
|
||||||
|
table: "expense_titles",
|
||||||
|
columns: new[] { "ExpenseCategoryId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_BranchId",
|
||||||
|
table: "expenses",
|
||||||
|
column: "BranchId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_CreatedBy",
|
||||||
|
table: "expenses",
|
||||||
|
column: "CreatedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_DocNo",
|
||||||
|
table: "expenses",
|
||||||
|
column: "DocNo",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_ExpenseCategoryId",
|
||||||
|
table: "expenses",
|
||||||
|
column: "ExpenseCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_ExpenseDate",
|
||||||
|
table: "expenses",
|
||||||
|
column: "ExpenseDate");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_ExpenseTitleId",
|
||||||
|
table: "expenses",
|
||||||
|
column: "ExpenseTitleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_PaymentStatus",
|
||||||
|
table: "expenses",
|
||||||
|
column: "PaymentStatus");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_Status",
|
||||||
|
table: "expenses",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_expenses_VendorId",
|
||||||
|
table: "expenses",
|
||||||
|
column: "VendorId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "expense_payments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "expenses");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "expense_titles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "expense_categories");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 54);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 55);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 44);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10153
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RenameDayEndToApprovedInvoices : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 42,
|
||||||
|
column: "Label",
|
||||||
|
value: "Approved Invoices");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 42,
|
||||||
|
column: "Label",
|
||||||
|
value: "Day End");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10192
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSalesPaymentMethodsAndDayEndReport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "CardAmount",
|
||||||
|
table: "sales_slips",
|
||||||
|
type: "decimal(18,4)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "CashAmount",
|
||||||
|
table: "sales_slips",
|
||||||
|
type: "decimal(18,4)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PaymentMethod",
|
||||||
|
table: "sales_invoice_payments",
|
||||||
|
type: "varchar(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Cash",
|
||||||
|
collation: "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "CardAmount",
|
||||||
|
table: "bundle_sales",
|
||||||
|
type: "decimal(18,4)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "CashAmount",
|
||||||
|
table: "bundle_sales",
|
||||||
|
type: "decimal(18,4)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[] { 46, "sales.day-end-report", "/dashboard/sales/day-end-report", null, "Day-End Report", 13, 5 });
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[] { 56, "NAV:sales.day-end-report", null, 46 });
|
||||||
|
|
||||||
|
// Every sale before the cash/card split existed was treated as cash (see SalesDayEnd's
|
||||||
|
// PaidTotal), so existing slips/bundles are all cash; invoice payments already default
|
||||||
|
// to 'Cash' through the column default above.
|
||||||
|
migrationBuilder.Sql("UPDATE `sales_slips` SET `CashAmount` = `PaidAmount`, `CardAmount` = 0;");
|
||||||
|
migrationBuilder.Sql("UPDATE `bundle_sales` SET `CashAmount` = `GrandTotal`, `CardAmount` = 0;");
|
||||||
|
|
||||||
|
// Grant the new report (56) to Admin and to every role that already reaches the
|
||||||
|
// cashier close-out (NAV:sales.day-end = 52) — it shows income, so it follows the
|
||||||
|
// close-out rather than plain invoicing. Same idempotent pattern as GrantExpensePermissions.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||||
|
SELECT r.`RoleId`, 56, 31
|
||||||
|
FROM `roles` r
|
||||||
|
WHERE LOWER(r.`Code`) = 'admin'
|
||||||
|
OR EXISTS (SELECT 1 FROM `role_permissions` rp
|
||||||
|
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` = 52);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 56;");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 56);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 46);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CardAmount",
|
||||||
|
table: "sales_slips");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CashAmount",
|
||||||
|
table: "sales_slips");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PaymentMethod",
|
||||||
|
table: "sales_invoice_payments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CardAmount",
|
||||||
|
table: "bundle_sales");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CashAmount",
|
||||||
|
table: "bundle_sales");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10354
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBankDepositModule : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "bank_deposits",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BankDepositId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
DocNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
BranchId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
DepositDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
FromGlCashAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
FromCashAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
FromCashAccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
ToGlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
ToBankAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
ToBankAccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
ToBankName = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
ToAccountNumber = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
DepositSlipNo = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
DepositedBy = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Remarks = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, defaultValue: "Posted", collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
GlPostedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
GlReversalJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CancelReason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CancelledBy = table.Column<int>(type: "int", nullable: true),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedBy = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_bank_deposits", x => x.BankDepositId);
|
||||||
|
table.CheckConstraint("ck_bank_deposits_amount", "`Amount` > 0");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bank_deposits_hr_branches_BranchId",
|
||||||
|
column: x => x.BranchId,
|
||||||
|
principalTable: "hr_branches",
|
||||||
|
principalColumn: "BranchId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_bank_deposits_users_CreatedBy",
|
||||||
|
column: x => x.CreatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[] { 47, "accounts.bank-deposits", "/dashboard/accounts/bank-deposits", null, "Bank Deposits", 12, 6 });
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[] { 57, "NAV:accounts.bank-deposits", null, 47 });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bank_deposits_BranchId_BusinessDate_Status",
|
||||||
|
table: "bank_deposits",
|
||||||
|
columns: new[] { "BranchId", "BusinessDate", "Status" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bank_deposits_CreatedBy",
|
||||||
|
table: "bank_deposits",
|
||||||
|
column: "CreatedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bank_deposits_DocNo",
|
||||||
|
table: "bank_deposits",
|
||||||
|
column: "DocNo",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_bank_deposits_ToGlBankAccountId",
|
||||||
|
table: "bank_deposits",
|
||||||
|
column: "ToGlBankAccountId");
|
||||||
|
|
||||||
|
// Grant the new Accounts sub-item (57) to Admin and to every role that already reaches
|
||||||
|
// Accounts (NAV:accounts 32, bank-accounts 26, cheque-books 33, received-cheques 34,
|
||||||
|
// expenses 54). Actions 31 = all, so Create (record) and Delete (cancel) included; admins
|
||||||
|
// can narrow it per role afterwards. Same idempotent pattern as GrantExpensePermissions.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||||
|
SELECT r.`RoleId`, 57, 31
|
||||||
|
FROM `roles` r
|
||||||
|
WHERE LOWER(r.`Code`) = 'admin'
|
||||||
|
OR EXISTS (SELECT 1 FROM `role_permissions` rp
|
||||||
|
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` IN (26, 32, 33, 34, 54));
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 57;");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "bank_deposits");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 57);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 47);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10354
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data-only: turns the new "stock.low" (Low Stock / Reorder Alert) module on for the in-app
|
||||||
|
/// bell. A module with no row is silently off, so without this no alert would ever reach anyone
|
||||||
|
/// until an admin ticked it under Settings → Notifications. Only inserts when the notification
|
||||||
|
/// config exists and the module has no row yet, so a choice already made is left alone.
|
||||||
|
/// </summary>
|
||||||
|
public partial class EnableLowStockAlerts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT INTO `notification_config_modules` (`NotificationConfigId`, `ModuleKey`, `EmailEnabled`, `SmsEnabled`, `InAppEnabled`)
|
||||||
|
SELECT c.`NotificationConfigId`, 'stock.low', 0, 0, 1
|
||||||
|
FROM `notification_config` c
|
||||||
|
WHERE c.`NotificationConfigId` = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `notification_config_modules` m WHERE m.`ModuleKey` = 'stock.low');
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM `notification_config_modules` WHERE `ModuleKey` = 'stock.low';");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDualApproval : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "varchar(30)",
|
||||||
|
maxLength: 30,
|
||||||
|
nullable: false,
|
||||||
|
collation: "utf8mb4_unicode_ci",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(20)",
|
||||||
|
oldMaxLength: 20)
|
||||||
|
.OldAnnotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "ApprovedLevel1At",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ApprovedLevel1By",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "ApprovedLevel2At",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ApprovedLevel2By",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CancelledAt",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "PostedAt",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RejectReason",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "varchar(250)",
|
||||||
|
maxLength: 250,
|
||||||
|
nullable: true,
|
||||||
|
collation: "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "RejectedAt",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RejectedBy",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RequiredApprovals",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RequestedSerialIds",
|
||||||
|
table: "stock_adjustment_lines",
|
||||||
|
type: "varchar(4000)",
|
||||||
|
maxLength: 4000,
|
||||||
|
nullable: true,
|
||||||
|
collation: "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RequestedSerialNos",
|
||||||
|
table: "stock_adjustment_lines",
|
||||||
|
type: "varchar(4000)",
|
||||||
|
maxLength: 4000,
|
||||||
|
nullable: true,
|
||||||
|
collation: "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "approval_config",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ApprovalConfigId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
StockAdjustmentLevels = table.Column<int>(type: "int", nullable: false),
|
||||||
|
StockAdjustmentLevel2ThresholdValue = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
PriceAdjustmentLevels = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PriceAdjustmentLevel2ThresholdPercent = table.Column<decimal>(type: "decimal(9,4)", precision: 9, scale: 4, nullable: false),
|
||||||
|
Level2RoleCodes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "int", nullable: true),
|
||||||
|
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_approval_config", x => x.ApprovalConfigId);
|
||||||
|
table.CheckConstraint("ck_approval_config_singleton", "`ApprovalConfigId` = 1");
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "document_approvals",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
DocumentApprovalId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
DocType = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
DocId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Level = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Decision = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
DecidedBy = table.Column<int>(type: "int", nullable: false),
|
||||||
|
DecidedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Comment = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_document_approvals", x => x.DocumentApprovalId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_document_approvals_users_DecidedBy",
|
||||||
|
column: x => x.DecidedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "price_adjustments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
PriceAdjustmentId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
DocNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Reason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: false, collation: "utf8mb4_unicode_ci"),
|
||||||
|
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false, defaultValue: "PendingApproval", collation: "utf8mb4_unicode_ci"),
|
||||||
|
RequiredApprovals = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedBy = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
ApprovedLevel1By = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ApprovedLevel1At = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ApprovedLevel2By = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ApprovedLevel2At = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
RejectedBy = table.Column<int>(type: "int", nullable: true),
|
||||||
|
RejectedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
RejectReason = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
AppliedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_price_adjustments", x => x.PriceAdjustmentId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_price_adjustments_users_CreatedBy",
|
||||||
|
column: x => x.CreatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "item_price_history",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ItemPriceHistoryId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
ItemId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PriceAdjustmentId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
OldMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
OldMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
OldFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
NewMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
NewMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
NewFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
RequestedBy = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ApprovedLevel1By = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ApprovedLevel2By = table.Column<int>(type: "int", nullable: true),
|
||||||
|
AppliedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_item_price_history", x => x.ItemPriceHistoryId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_item_price_history_items_ItemId",
|
||||||
|
column: x => x.ItemId,
|
||||||
|
principalTable: "items",
|
||||||
|
principalColumn: "ItemId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_item_price_history_price_adjustments_PriceAdjustmentId",
|
||||||
|
column: x => x.PriceAdjustmentId,
|
||||||
|
principalTable: "price_adjustments",
|
||||||
|
principalColumn: "PriceAdjustmentId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "price_adjustment_lines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
PriceAdjustmentLineId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
PriceAdjustmentId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ItemId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
OldMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
OldMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
OldFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
NewMinPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
NewMaxPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
NewFlatPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_price_adjustment_lines", x => x.PriceAdjustmentLineId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_price_adjustment_lines_items_ItemId",
|
||||||
|
column: x => x.ItemId,
|
||||||
|
principalTable: "items",
|
||||||
|
principalColumn: "ItemId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_price_adjustment_lines_price_adjustments_PriceAdjustmentId",
|
||||||
|
column: x => x.PriceAdjustmentId,
|
||||||
|
principalTable: "price_adjustments",
|
||||||
|
principalColumn: "PriceAdjustmentId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "approval_config",
|
||||||
|
columns: new[] { "ApprovalConfigId", "Level2RoleCodes", "PriceAdjustmentLevel2ThresholdPercent", "PriceAdjustmentLevels", "RowVersion", "StockAdjustmentLevel2ThresholdValue", "StockAdjustmentLevels", "UpdatedAt", "UpdatedBy" },
|
||||||
|
values: new object[] { 1, null, 0m, 2, 1u, 0m, 2, null, null });
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 48, "products.price-adjustments", "/dashboard/products/price-adjustments", null, "Price Adjustments", 2, 8 },
|
||||||
|
{ 49, "settings.approvals", "/dashboard/settings/approvals", null, "Approvals", 9, 4 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 58, "NAV:products.price-adjustments", null, 48 },
|
||||||
|
{ 59, "NAV:settings.approvals", null, 49 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stock_adjustments_Status",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_document_approvals_DecidedBy",
|
||||||
|
table: "document_approvals",
|
||||||
|
column: "DecidedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_document_approvals_DocType_DocId",
|
||||||
|
table: "document_approvals",
|
||||||
|
columns: new[] { "DocType", "DocId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_item_price_history_ItemId_AppliedAt",
|
||||||
|
table: "item_price_history",
|
||||||
|
columns: new[] { "ItemId", "AppliedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_item_price_history_PriceAdjustmentId",
|
||||||
|
table: "item_price_history",
|
||||||
|
column: "PriceAdjustmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_price_adjustment_lines_ItemId",
|
||||||
|
table: "price_adjustment_lines",
|
||||||
|
column: "ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_price_adjustment_lines_PriceAdjustmentId",
|
||||||
|
table: "price_adjustment_lines",
|
||||||
|
column: "PriceAdjustmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_price_adjustments_CreatedBy",
|
||||||
|
table: "price_adjustments",
|
||||||
|
column: "CreatedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_price_adjustments_DocNo",
|
||||||
|
table: "price_adjustments",
|
||||||
|
column: "DocNo",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_price_adjustments_Status",
|
||||||
|
table: "price_adjustments",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
// Existing adjustments all auto-posted, so they need no approval rows — RequiredApprovals = 0
|
||||||
|
// (the column default) marks them as "posted without approval".
|
||||||
|
|
||||||
|
// Price Adjustments (58): Admin and every role that already reaches Products (NAV:products 2,
|
||||||
|
// products.item 11). Approval settings (59): Admin only. Actions 31 = all, including Approve —
|
||||||
|
// admins should narrow Approve to the people who really approve (at least two per branch).
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||||
|
SELECT r.`RoleId`, 58, 31
|
||||||
|
FROM `roles` r
|
||||||
|
WHERE LOWER(r.`Code`) = 'admin'
|
||||||
|
OR EXISTS (SELECT 1 FROM `role_permissions` rp
|
||||||
|
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` IN (2, 11));
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
|
||||||
|
SELECT r.`RoleId`, 59, 31 FROM `roles` r WHERE LOWER(r.`Code`) = 'admin';
|
||||||
|
""");
|
||||||
|
|
||||||
|
// The approval notifications are on in the bell by default — without them nobody learns a
|
||||||
|
// request is waiting. Same guarded insert as EnableLowStockAlerts.
|
||||||
|
foreach (var key in new[] { "approval.pending", "approval.approved", "approval.rejected" })
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql($"""
|
||||||
|
INSERT INTO `notification_config_modules` (`NotificationConfigId`, `ModuleKey`, `EmailEnabled`, `SmsEnabled`, `InAppEnabled`)
|
||||||
|
SELECT c.`NotificationConfigId`, '{key}', 0, 0, 1
|
||||||
|
FROM `notification_config` c
|
||||||
|
WHERE c.`NotificationConfigId` = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `notification_config_modules` m WHERE m.`ModuleKey` = '{key}');
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` IN (58, 59);");
|
||||||
|
migrationBuilder.Sql("DELETE FROM `notification_config_modules` WHERE `ModuleKey` IN ('approval.pending', 'approval.approved', 'approval.rejected');");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "approval_config");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "document_approvals");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "item_price_history");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "price_adjustment_lines");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "price_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_stock_adjustments_Status",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 58);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 59);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 48);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 49);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ApprovedLevel1At",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ApprovedLevel1By",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ApprovedLevel2At",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ApprovedLevel2By",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CancelledAt",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PostedAt",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RejectReason",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RejectedAt",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RejectedBy",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RequiredApprovals",
|
||||||
|
table: "stock_adjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RequestedSerialIds",
|
||||||
|
table: "stock_adjustment_lines");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RequestedSerialNos",
|
||||||
|
table: "stock_adjustment_lines");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "stock_adjustments",
|
||||||
|
type: "varchar(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
collation: "utf8mb4_unicode_ci",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "varchar(30)",
|
||||||
|
oldMaxLength: 30)
|
||||||
|
.OldAnnotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,58 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ApprovalConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ApprovalConfigId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Level2RoleCodes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PriceAdjustmentLevel2ThresholdPercent")
|
||||||
|
.HasPrecision(9, 4)
|
||||||
|
.HasColumnType("decimal(9,4)");
|
||||||
|
|
||||||
|
b.Property<int>("PriceAdjustmentLevels")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("int unsigned");
|
||||||
|
|
||||||
|
b.Property<decimal>("StockAdjustmentLevel2ThresholdValue")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("StockAdjustmentLevels")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("UpdatedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("ApprovalConfigId");
|
||||||
|
|
||||||
|
b.ToTable("approval_config", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_approval_config_singleton", "`ApprovalConfigId` = 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ApprovalConfigId = 1,
|
||||||
|
PriceAdjustmentLevel2ThresholdPercent = 0m,
|
||||||
|
PriceAdjustmentLevels = 2,
|
||||||
|
RowVersion = 1u,
|
||||||
|
StockAdjustmentLevel2ThresholdValue = 0m,
|
||||||
|
StockAdjustmentLevels = 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("AttendanceRecordId")
|
b.Property<int>("AttendanceRecordId")
|
||||||
@@ -214,6 +266,133 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("audit_logs", (string)null);
|
b.ToTable("audit_logs", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BankDeposit", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BankDepositId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("BankDepositId"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("BranchId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("BusinessDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("CancelReason")
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("varchar(250)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("CancelledBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("CreatedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("DepositDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("DepositSlipNo")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("DepositedBy")
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("varchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("DocNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
|
b.Property<string>("FromCashAccountCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("FromCashAccountName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<long>("FromGlCashAccountId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("GlJournalNo")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("GlPostedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("GlReversalJournalNo")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Remarks")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("int unsigned");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)")
|
||||||
|
.HasDefaultValue("Posted");
|
||||||
|
|
||||||
|
b.Property<string>("ToAccountNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ToBankAccountCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("ToBankAccountName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ToBankName")
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("varchar(150)");
|
||||||
|
|
||||||
|
b.Property<long>("ToGlBankAccountId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("BankDepositId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedBy");
|
||||||
|
|
||||||
|
b.HasIndex("DocNo")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("ToGlBankAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("BranchId", "BusinessDate", "Status");
|
||||||
|
|
||||||
|
b.ToTable("bank_deposits", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_bank_deposits_amount", "`Amount` > 0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("BatchId")
|
b.Property<int>("BatchId")
|
||||||
@@ -435,6 +614,14 @@ namespace ERPCore.Migrations
|
|||||||
b.Property<int>("BundleSaleTemplateId")
|
b.Property<int>("BundleSaleTemplateId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("CardAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CashAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.Property<int>("CashierUserId")
|
b.Property<int>("CashierUserId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -904,6 +1091,49 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("hr_designations", (string)null);
|
b.ToTable("hr_designations", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.DocumentApproval", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("DocumentApprovalId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("DocumentApprovalId"));
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("varchar(250)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DecidedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("DecidedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Decision")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<int>("DocId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("DocType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("varchar(10)");
|
||||||
|
|
||||||
|
b.Property<int>("Level")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("DocumentApprovalId");
|
||||||
|
|
||||||
|
b.HasIndex("DecidedBy");
|
||||||
|
|
||||||
|
b.HasIndex("DocType", "DocId");
|
||||||
|
|
||||||
|
b.ToTable("document_approvals", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("EmployeeId")
|
b.Property<int>("EmployeeId")
|
||||||
@@ -1989,6 +2219,65 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("item_images", (string)null);
|
b.ToTable("item_images", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemPriceHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ItemPriceHistoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("ItemPriceHistoryId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("AppliedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ApprovedLevel1By")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("ApprovedLevel2By")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("NewFlatPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("NewMaxPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("NewMinPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("OldFlatPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("OldMaxPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("OldMinPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int?>("PriceAdjustmentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("RequestedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("ItemPriceHistoryId");
|
||||||
|
|
||||||
|
b.HasIndex("PriceAdjustmentId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId", "AppliedAt");
|
||||||
|
|
||||||
|
b.ToTable("item_price_history", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("ReorderId")
|
b.Property<int>("ReorderId")
|
||||||
@@ -3343,6 +3632,42 @@ namespace ERPCore.Migrations
|
|||||||
PermissionId = 53,
|
PermissionId = 53,
|
||||||
Code = "NAV:products.tax-rates",
|
Code = "NAV:products.tax-rates",
|
||||||
SubNavItemId = 43
|
SubNavItemId = 43
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 54,
|
||||||
|
Code = "NAV:accounts.expenses",
|
||||||
|
SubNavItemId = 44
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 55,
|
||||||
|
Code = "NAV:accounts.expense-setup",
|
||||||
|
SubNavItemId = 45
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 56,
|
||||||
|
Code = "NAV:sales.day-end-report",
|
||||||
|
SubNavItemId = 46
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 57,
|
||||||
|
Code = "NAV:accounts.bank-deposits",
|
||||||
|
SubNavItemId = 47
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 58,
|
||||||
|
Code = "NAV:products.price-adjustments",
|
||||||
|
SubNavItemId = 48
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 59,
|
||||||
|
Code = "NAV:settings.approvals",
|
||||||
|
SubNavItemId = 49
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3390,6 +3715,131 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("po_lines", (string)null);
|
b.ToTable("po_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustment", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("PriceAdjustmentId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("PriceAdjustmentId"));
|
||||||
|
|
||||||
|
b.Property<DateTime?>("AppliedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ApprovedLevel1At")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ApprovedLevel1By")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ApprovedLevel2At")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ApprovedLevel2By")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("CreatedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("DocNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("varchar(250)");
|
||||||
|
|
||||||
|
b.Property<string>("RejectReason")
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("varchar(250)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RejectedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("RequiredApprovals")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("int unsigned");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)")
|
||||||
|
.HasDefaultValue("PendingApproval");
|
||||||
|
|
||||||
|
b.HasKey("PriceAdjustmentId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedBy");
|
||||||
|
|
||||||
|
b.HasIndex("DocNo")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("price_adjustments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustmentLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("PriceAdjustmentLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("PriceAdjustmentLineId"));
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("NewFlatPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("NewMaxPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("NewMinPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("OldFlatPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("OldMaxPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("OldMinPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("PriceAdjustmentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("PriceAdjustmentLineId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("PriceAdjustmentId");
|
||||||
|
|
||||||
|
b.ToTable("price_adjustment_lines", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("ConfigId")
|
b.Property<int>("ConfigId")
|
||||||
@@ -4910,6 +5360,13 @@ namespace ERPCore.Migrations
|
|||||||
b.Property<DateTime>("PaymentDate")
|
b.Property<DateTime>("PaymentDate")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("PaymentMethod")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)")
|
||||||
|
.HasDefaultValue("Cash");
|
||||||
|
|
||||||
b.Property<string>("Reference")
|
b.Property<string>("Reference")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("varchar(100)");
|
.HasColumnType("varchar(100)");
|
||||||
@@ -5112,6 +5569,14 @@ namespace ERPCore.Migrations
|
|||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CardAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CashAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.Property<int>("CashierUserId")
|
b.Property<int>("CashierUserId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -5549,9 +6014,24 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("AdjustmentId"));
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("AdjustmentId"));
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ApprovedLevel1At")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ApprovedLevel1By")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ApprovedLevel2At")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ApprovedLevel2By")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("BranchId")
|
b.Property<int>("BranchId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
@@ -5570,17 +6050,33 @@ namespace ERPCore.Migrations
|
|||||||
b.Property<DateTime?>("GlPostedAt")
|
b.Property<DateTime?>("GlPostedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PostedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<int>("ReasonCodeId")
|
b.Property<int>("ReasonCodeId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("RejectReason")
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("varchar(250)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RejectedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedBy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("RequiredApprovals")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<uint>("RowVersion")
|
b.Property<uint>("RowVersion")
|
||||||
.IsConcurrencyToken()
|
.IsConcurrencyToken()
|
||||||
.HasColumnType("int unsigned");
|
.HasColumnType("int unsigned");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(30)
|
||||||
.HasColumnType("varchar(20)");
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
b.Property<int>("WarehouseId")
|
b.Property<int>("WarehouseId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
@@ -5596,6 +6092,8 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ReasonCodeId");
|
b.HasIndex("ReasonCodeId");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.HasIndex("WarehouseId");
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
b.ToTable("stock_adjustments", (string)null);
|
b.ToTable("stock_adjustments", (string)null);
|
||||||
@@ -5625,6 +6123,14 @@ namespace ERPCore.Migrations
|
|||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("RequestedSerialIds")
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("varchar(4000)");
|
||||||
|
|
||||||
|
b.Property<string>("RequestedSerialNos")
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("varchar(4000)");
|
||||||
|
|
||||||
b.Property<int?>("SerialId")
|
b.Property<int?>("SerialId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -6189,6 +6695,16 @@ namespace ERPCore.Migrations
|
|||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 48,
|
||||||
|
Code = "products.price-adjustments",
|
||||||
|
Href = "/dashboard/products/price-adjustments",
|
||||||
|
Label = "Price Adjustments",
|
||||||
|
NavItemId = 2,
|
||||||
|
SortOrder = 8,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 29,
|
SubNavItemId = 29,
|
||||||
Code = "settings.branches",
|
Code = "settings.branches",
|
||||||
@@ -6229,6 +6745,16 @@ namespace ERPCore.Migrations
|
|||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 49,
|
||||||
|
Code = "settings.approvals",
|
||||||
|
Href = "/dashboard/settings/approvals",
|
||||||
|
Label = "Approvals",
|
||||||
|
NavItemId = 9,
|
||||||
|
SortOrder = 4,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 40,
|
SubNavItemId = 40,
|
||||||
Code = "sales.invoices",
|
Code = "sales.invoices",
|
||||||
@@ -6263,12 +6789,22 @@ namespace ERPCore.Migrations
|
|||||||
SubNavItemId = 42,
|
SubNavItemId = 42,
|
||||||
Code = "sales.day-end",
|
Code = "sales.day-end",
|
||||||
Href = "/dashboard/sales/day-end",
|
Href = "/dashboard/sales/day-end",
|
||||||
Label = "Day End",
|
Label = "Approved Invoices",
|
||||||
NavItemId = 13,
|
NavItemId = 13,
|
||||||
SortOrder = 4,
|
SortOrder = 4,
|
||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 46,
|
||||||
|
Code = "sales.day-end-report",
|
||||||
|
Href = "/dashboard/sales/day-end-report",
|
||||||
|
Label = "Day-End Report",
|
||||||
|
NavItemId = 13,
|
||||||
|
SortOrder = 5,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 30,
|
SubNavItemId = 30,
|
||||||
Code = "procurement.vendors",
|
Code = "procurement.vendors",
|
||||||
@@ -6429,6 +6965,36 @@ namespace ERPCore.Migrations
|
|||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 44,
|
||||||
|
Code = "accounts.expenses",
|
||||||
|
Href = "/dashboard/accounts/expenses",
|
||||||
|
Label = "Expenses",
|
||||||
|
NavItemId = 12,
|
||||||
|
SortOrder = 4,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 45,
|
||||||
|
Code = "accounts.expense-setup",
|
||||||
|
Href = "/dashboard/accounts/expense-setup",
|
||||||
|
Label = "Expense Setup",
|
||||||
|
NavItemId = 12,
|
||||||
|
SortOrder = 5,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 47,
|
||||||
|
Code = "accounts.bank-deposits",
|
||||||
|
Href = "/dashboard/accounts/bank-deposits",
|
||||||
|
Label = "Bank Deposits",
|
||||||
|
NavItemId = 12,
|
||||||
|
SortOrder = 6,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 27,
|
SubNavItemId = 27,
|
||||||
Code = "production.templates",
|
Code = "production.templates",
|
||||||
@@ -7245,6 +7811,25 @@ namespace ERPCore.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.BankDeposit", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BranchId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Branch");
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
@@ -7415,6 +8000,17 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("ParentDepartment");
|
b.Navigation("ParentDepartment");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.DocumentApproval", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "Decider")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DecidedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Decider");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
||||||
@@ -7746,6 +8342,24 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Item");
|
b.Navigation("Item");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemPriceHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.PriceAdjustment", "PriceAdjustment")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PriceAdjustmentId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("PriceAdjustment");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
@@ -7985,6 +8599,36 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustmentLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.PriceAdjustment", "PriceAdjustment")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("PriceAdjustmentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("PriceAdjustment");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||||
@@ -9377,6 +10021,16 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.Expense", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Payments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ExpenseCategory", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Titles");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
@@ -9419,6 +10073,11 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.PriceAdjustment", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Edges");
|
b.Navigation("Edges");
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
|
|||||||
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
|
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
|
||||||
|
|
||||||
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
|
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers(o => o.Filters.Add<ERPCore.Infra.Filters.LowStockAlertFilter>())
|
||||||
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
// EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using
|
// EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using
|
||||||
@@ -149,6 +149,19 @@ builder.Services.AddScoped<IGrnService, GrnService>();
|
|||||||
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
|
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
|
||||||
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
|
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
|
||||||
|
|
||||||
|
// Accounts: expenses (docs/EXPENSE_MODULE_PLAN.md). ExpenseService is also registered as itself:
|
||||||
|
// ExpensePaymentService reuses its post step so Post & Pay is one transaction.
|
||||||
|
builder.Services.AddScoped<IExpenseSetupService, ExpenseSetupService>();
|
||||||
|
builder.Services.AddScoped<ExpenseService>();
|
||||||
|
builder.Services.AddScoped<IExpenseService>(sp => sp.GetRequiredService<ExpenseService>());
|
||||||
|
builder.Services.AddScoped<IExpensePaymentService, ExpensePaymentService>();
|
||||||
|
|
||||||
|
// Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md). The business-day clock
|
||||||
|
// is shared with the Day-End Sales Report so both agree on what "today" is.
|
||||||
|
builder.Services.AddSingleton<BusinessDayClock>();
|
||||||
|
builder.Services.AddScoped<ICashInHandService, CashInHandService>();
|
||||||
|
builder.Services.AddScoped<IBankDepositService, BankDepositService>();
|
||||||
|
|
||||||
// Sales (Phase 1)
|
// Sales (Phase 1)
|
||||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||||
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
|
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
|
||||||
@@ -163,16 +176,27 @@ builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSugge
|
|||||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||||
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
||||||
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
|
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
|
||||||
|
builder.Services.AddScoped<IDayEndReportService, DayEndReportService>();
|
||||||
|
|
||||||
// Stock transactions + reference data (docs/11 §5–6)
|
// Stock transactions + reference data (docs/11 §5–6)
|
||||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||||
builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>();
|
builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>();
|
||||||
builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>();
|
builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>();
|
||||||
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
||||||
builder.Services.AddScoped<IAdjustmentService, AdjustmentService>();
|
// AdjustmentService is also registered as itself: CountService submits its variances through it.
|
||||||
|
builder.Services.AddScoped<AdjustmentService>();
|
||||||
|
builder.Services.AddScoped<IAdjustmentService>(sp => sp.GetRequiredService<AdjustmentService>());
|
||||||
|
builder.Services.AddScoped<IApprovalService, ApprovalService>();
|
||||||
|
builder.Services.AddScoped<IPriceAdjustmentService, PriceAdjustmentService>();
|
||||||
|
builder.Services.AddScoped<ApprovalInboxService>();
|
||||||
builder.Services.AddScoped<ITransferService, TransferService>();
|
builder.Services.AddScoped<ITransferService, TransferService>();
|
||||||
builder.Services.AddScoped<ICountService, CountService>();
|
builder.Services.AddScoped<ICountService, CountService>();
|
||||||
builder.Services.AddScoped<IReorderService, ReorderService>();
|
builder.Services.AddScoped<IReorderService, ReorderService>();
|
||||||
|
// Low-stock alerts: FIFO issues record into the tracker; the global LowStockAlertFilter checks
|
||||||
|
// them against reorder points after each successful request and raises "stock.low".
|
||||||
|
builder.Services.AddScoped<ERPCore.Services.Stock.LowStockTracker>();
|
||||||
|
builder.Services.AddScoped<ERPCore.Services.Stock.LowStockAlertService>();
|
||||||
|
builder.Services.AddScoped<ERPCore.Infra.Filters.LowStockAlertFilter>();
|
||||||
builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
|
builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
|
||||||
|
|
||||||
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
@@ -15,27 +17,36 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
|
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). An adjustment is a
|
||||||
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
|
/// request with a mandatory reason code; it moves stock only once approved by one or two people
|
||||||
/// decrease, layer create on an increase) + ledger posting is delegated to
|
/// other than the requester (docs/DUAL_APPROVAL_PLAN.md §5, rules in <see cref="IApprovalService"/>).
|
||||||
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05). Also posts
|
/// With approvals switched off it posts on create, as it always used to. Posting — FIFO consume on a
|
||||||
/// a real GL journal entry — increases and decreases post as separate Inventory/Gain and
|
/// decrease, layer create on an increase, ledger, serial moves and the GL journal — is one
|
||||||
/// Loss/Inventory lines respectively (never netted against each other), so a count that's
|
/// transaction (NFR-02/05) and is re-validated at approval time, since stock and serials may have
|
||||||
/// simultaneously over on one item and under on another shows both, not a false net.
|
/// moved while the request waited. Increases and decreases post as separate Inventory/Gain and
|
||||||
|
/// Loss/Inventory GL lines (never netted), so a count over on one item and under on another shows both.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AdjustmentService : IAdjustmentService
|
public sealed class AdjustmentService : IAdjustmentService
|
||||||
{
|
{
|
||||||
|
public const string ApprovalDocType = "ADJ";
|
||||||
|
public const string ApprovalNavCode = "stock.adjustments";
|
||||||
|
|
||||||
|
private static readonly IReadOnlyCollection<SerialStatus> WriteOffStatuses = [SerialStatus.InStock, SerialStatus.Damaged];
|
||||||
|
|
||||||
private readonly IRepository<StockAdjustment> _adjustments;
|
private readonly IRepository<StockAdjustment> _adjustments;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||||
private readonly IRepository<StockLedger> _ledger;
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
private readonly IStockMutator _mutator;
|
private readonly IStockMutator _mutator;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IBranchConfigService _branchConfig;
|
private readonly IBranchConfigService _branchConfig;
|
||||||
private readonly INotificationService _notifications;
|
private readonly INotificationService _notifications;
|
||||||
|
private readonly IApprovalService _approvals;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly ErpDbContext _db;
|
||||||
private readonly ISerialTrackingService _serialTracking;
|
private readonly ISerialTrackingService _serialTracking;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly IGlAccountConfigService _glAccounts;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
@@ -53,18 +64,19 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
_reasonCodes = reasonCodes;
|
_reasonCodes = reasonCodes;
|
||||||
_ledger = ledger;
|
_ledger = ledger;
|
||||||
_mutator = mutator;
|
_mutator = mutator;
|
||||||
|
_fifo = fifo;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_branchConfig = branchConfig;
|
_branchConfig = branchConfig;
|
||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
|
_approvals = approvals;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
|
_db = db;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glAccounts = glAccounts;
|
_glAccounts = glAccounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
|
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.</summary>
|
||||||
/// <see cref="CountService"/>'s variance posting mirrors this exact logic for the same reason
|
|
||||||
/// (it creates a <see cref="StockAdjustment"/> through the same <see cref="IStockMutator"/> call).</summary>
|
|
||||||
private async Task<string?> PostAdjustmentJournalAsync(
|
private async Task<string?> PostAdjustmentJournalAsync(
|
||||||
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
|
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -99,7 +111,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||||
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default)
|
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _adjustments.Query().AsNoTracking();
|
var q = _adjustments.Query().AsNoTracking();
|
||||||
|
|
||||||
@@ -112,13 +124,15 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
// Warehouses are shared across branches — the adjustment carries its own owning BranchId.
|
// Warehouses are shared across branches — the adjustment carries its own owning BranchId.
|
||||||
if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
|
if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
|
||||||
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
|
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
|
||||||
|
if (status is not null) q = q.Where(a => a.Status == status);
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(a => a.AdjustmentId)
|
var rows = await q.OrderByDescending(a => a.AdjustmentId)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(a => new AdjustmentSummaryDto(
|
.Select(a => new AdjustmentSummaryDto(
|
||||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
|
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
|
||||||
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
|
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo, a.RequiredApprovals,
|
||||||
|
a.Status == AdjustmentStatus.PendingApproval ? 1 : a.Status == AdjustmentStatus.PendingSecondApproval ? 2 : null))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -129,20 +143,22 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
var adjustment = await _adjustments.Query().AsNoTracking()
|
var adjustment = await _adjustments.Query().AsNoTracking()
|
||||||
.Include(a => a.Lines)
|
.Include(a => a.Lines)
|
||||||
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
|
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
|
||||||
if (adjustment is null) return null;
|
return adjustment is null ? null : await ToDtoAsync(adjustment, ct);
|
||||||
|
|
||||||
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
|
|
||||||
// so the refs this adjustment posted are recovered by source-doc lookup.
|
|
||||||
var ledgerRefs = await _ledger.Query().AsNoTracking()
|
|
||||||
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
|
|
||||||
.OrderBy(l => l.LedgerId)
|
|
||||||
.Select(l => l.LedgerId)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return ToDto(adjustment, ledgerRefs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
|
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (adjustment, refs) = await SubmitAsync(request, null, ct);
|
||||||
|
return await ToDtoAsync(adjustment, ct, refs.Select(l => l.LedgerId).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates and saves an adjustment request; posts it straight away when no approval is
|
||||||
|
/// required. <paramref name="alsoInTransaction"/> lets a caller (a stock count) commit its own
|
||||||
|
/// change together with the adjustment. Returns the ledger entries posted (none while pending).
|
||||||
|
/// </summary>
|
||||||
|
internal async Task<(StockAdjustment Adjustment, IReadOnlyList<StockLedger> Refs)> SubmitAsync(
|
||||||
|
CreateAdjustmentRequest request, Func<CancellationToken, Task>? alsoInTransaction, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (request.ReasonCodeId is null)
|
if (request.ReasonCodeId is null)
|
||||||
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400);
|
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400);
|
||||||
@@ -157,77 +173,300 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
if (reason.Context != ReasonContext.Adjustment)
|
if (reason.Context != ReasonContext.Adjustment)
|
||||||
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
|
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
|
||||||
|
|
||||||
// Serials per line: outbound ones are validated as on hand; inbound ones are created (or
|
// Validate every line (and its serials) now, so a bad request is refused up front rather
|
||||||
// re-found) in the context now and get their ids on the flush below.
|
// than at approval time. The value drives the approval threshold.
|
||||||
var serialsByLine = new List<IReadOnlyList<Serial>>();
|
var value = 0m;
|
||||||
var documentSerials = new HashSet<int>();
|
|
||||||
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var line in request.Lines)
|
foreach (var line in request.Lines)
|
||||||
{
|
{
|
||||||
if (line.QtyDelta == 0)
|
if (line.QtyDelta == 0)
|
||||||
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
|
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
|
||||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
|
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
|
||||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||||
serialsByLine.Add(line.QtyDelta < 0
|
value += Math.Abs(line.QtyDelta) * await UnitValueAsync(line.ItemId, request.WarehouseId, ct);
|
||||||
? await _serialTracking.ResolveOutboundAsync(item, request.WarehouseId, -line.QtyDelta, line.SerialIds,
|
|
||||||
documentSerials, new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct)
|
|
||||||
: await _serialTracking.ResolveInboundAsync(item, request.WarehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
|
|
||||||
}
|
}
|
||||||
|
await ResolveSerialsAsync(request.WarehouseId, request.Lines.Select(ToPlan).ToList(), ct);
|
||||||
|
// Validated only: posting resolves them again and creates any new ones exactly once.
|
||||||
|
DiscardUnsavedSerials();
|
||||||
|
|
||||||
|
var required = await _approvals.RequiredForStockAdjustmentAsync(value, ct);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var (adjustment, refs) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
|
|
||||||
{
|
{
|
||||||
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
|
||||||
var entity = new StockAdjustment
|
var entity = new StockAdjustment
|
||||||
{
|
{
|
||||||
DocNo = docNo,
|
DocNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token),
|
||||||
WarehouseId = request.WarehouseId,
|
WarehouseId = request.WarehouseId,
|
||||||
BranchId = branchId,
|
BranchId = branchId,
|
||||||
ReasonCodeId = request.ReasonCodeId.Value,
|
ReasonCodeId = request.ReasonCodeId.Value,
|
||||||
Status = AdjustmentStatus.Posted,
|
Status = required == 0 ? AdjustmentStatus.Posted : AdjustmentStatus.PendingApproval,
|
||||||
|
RequiredApprovals = required,
|
||||||
CreatedBy = _currentUser.AuditUserId,
|
CreatedBy = _currentUser.AuditUserId,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
Lines = request.Lines.Select((l, i) => new StockAdjustmentLine
|
Lines = request.Lines.Select(l => new StockAdjustmentLine
|
||||||
{
|
{
|
||||||
ItemId = l.ItemId,
|
ItemId = l.ItemId,
|
||||||
BinId = l.BinId,
|
BinId = l.BinId,
|
||||||
BatchId = l.BatchId,
|
BatchId = l.BatchId,
|
||||||
QtyDelta = l.QtyDelta,
|
QtyDelta = l.QtyDelta,
|
||||||
Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null
|
RequestedSerialIds = l.SerialIds is { Count: > 0 } ? JsonSerializer.Serialize(l.SerialIds) : null,
|
||||||
|
RequestedSerialNos = l.SerialNos is { Count: > 0 } ? JsonSerializer.Serialize(l.SerialNos) : null
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
await _adjustments.AddAsync(entity, token);
|
await _adjustments.AddAsync(entity, token);
|
||||||
await _uow.SaveChangesAsync(token); // flush so AdjustmentId (and new SerialIds) are valid
|
|
||||||
|
|
||||||
var deltas = request.Lines.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
|
IReadOnlyList<StockLedger> posted = [];
|
||||||
serialsByLine[i].Select(s => s.SerialId).ToList())).ToList();
|
if (required == 0)
|
||||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
|
|
||||||
|
|
||||||
for (var i = 0; i < request.Lines.Count; i++)
|
|
||||||
{
|
{
|
||||||
var line = request.Lines[i];
|
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
|
||||||
await _serialTracking.MoveAsync(serialsByLine[i],
|
posted = await PostInCurrentTransactionAsync(entity, token);
|
||||||
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
|
|
||||||
line.QtyDelta < 0 ? null : request.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
|
|
||||||
DocumentTypes.Adjustment, entity.AdjustmentId, now, token);
|
|
||||||
}
|
}
|
||||||
|
if (alsoInTransaction is not null) await alsoInTransaction(token);
|
||||||
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
return (entity, posted);
|
||||||
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
|
|
||||||
|
|
||||||
return (entity, refs);
|
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
|
if (required == 0)
|
||||||
|
{
|
||||||
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was created.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
|
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was created.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
|
||||||
|
}
|
||||||
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
|
else
|
||||||
|
{
|
||||||
|
await _approvals.NotifyPendingAsync(Target(adjustment), await SummaryAsync(adjustment, ct), ct);
|
||||||
|
}
|
||||||
|
return (adjustment, refs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
|
public async Task<AdjustmentDto> ApproveAsync(int adjustmentId, string? comment, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var adjustment = await LoadPendingAsync(adjustmentId, ct);
|
||||||
|
var target = Target(adjustment);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var outcome = await _approvals.DecideAsync(target, approve: true, comment, ct);
|
||||||
|
IReadOnlyList<StockLedger> refs = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
refs = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
if (target.CurrentLevel == 1)
|
||||||
|
{
|
||||||
|
adjustment.ApprovedLevel1By = _currentUser.AuditUserId;
|
||||||
|
adjustment.ApprovedLevel1At = now;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
adjustment.ApprovedLevel2By = _currentUser.AuditUserId;
|
||||||
|
adjustment.ApprovedLevel2At = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome == ApprovalOutcome.AdvancedToLevel2)
|
||||||
|
{
|
||||||
|
adjustment.Status = AdjustmentStatus.PendingSecondApproval;
|
||||||
|
return (IReadOnlyList<StockLedger>)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustment.Status = AdjustmentStatus.Posted;
|
||||||
|
return await PostInCurrentTransactionAsync(adjustment, token);
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome == ApprovalOutcome.AdvancedToLevel2)
|
||||||
|
{
|
||||||
|
await _approvals.NotifyPendingAsync(Target(adjustment), await SummaryAsync(adjustment, ct), ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.approved",
|
||||||
|
$"Approved: {adjustment.DocNo}", $"Stock adjustment {adjustment.DocNo} was approved and posted to stock.",
|
||||||
|
ApprovalDocType, adjustment.AdjustmentId, ct);
|
||||||
|
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was posted.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ToDtoAsync(adjustment, ct, refs.Select(r => r.LedgerId).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdjustmentDto> RejectAsync(int adjustmentId, string reason, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var adjustment = await LoadPendingAsync(adjustmentId, ct);
|
||||||
|
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
|
||||||
|
|
||||||
|
adjustment.Status = AdjustmentStatus.Rejected;
|
||||||
|
adjustment.RejectedBy = _currentUser.AuditUserId;
|
||||||
|
adjustment.RejectedAt = DateTime.UtcNow;
|
||||||
|
adjustment.RejectReason = reason.Trim();
|
||||||
|
await SaveDecisionAsync(adjustment, ct);
|
||||||
|
|
||||||
|
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
|
||||||
|
$"Rejected: {adjustment.DocNo}", $"Stock adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}",
|
||||||
|
ApprovalDocType, adjustment.AdjustmentId, ct);
|
||||||
|
return await ToDtoAsync(adjustment, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdjustmentDto> CancelAsync(int adjustmentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var adjustment = await LoadPendingAsync(adjustmentId, ct);
|
||||||
|
if (adjustment.CreatedBy != _currentUser.AuditUserId)
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
|
||||||
|
|
||||||
|
adjustment.Status = AdjustmentStatus.Cancelled;
|
||||||
|
adjustment.CancelledAt = DateTime.UtcNow;
|
||||||
|
await SaveDecisionAsync(adjustment, ct);
|
||||||
|
return await ToDtoAsync(adjustment, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Posting ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>What a line asks for, from a request or from a saved pending line.</summary>
|
||||||
|
private sealed record LinePlan(int ItemId, int? BinId, int? BatchId, decimal QtyDelta, List<int>? SerialIds, List<string>? SerialNos);
|
||||||
|
|
||||||
|
private static LinePlan ToPlan(CreateAdjustmentLineInput l) => new(l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialIds, l.SerialNos);
|
||||||
|
|
||||||
|
private static LinePlan ToPlan(StockAdjustmentLine l) => new(
|
||||||
|
l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
|
||||||
|
l.RequestedSerialIds is null ? null : JsonSerializer.Deserialize<List<int>>(l.RequestedSerialIds),
|
||||||
|
l.RequestedSerialNos is null ? null : JsonSerializer.Deserialize<List<string>>(l.RequestedSerialNos));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outbound serials must be at the warehouse (InStock or Damaged); inbound numbers are new or
|
||||||
|
/// out of stock (new ones are added to the context, unsaved). Same rules on request and posting.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<IReadOnlyList<Serial>>> ResolveSerialsAsync(int warehouseId, IReadOnlyList<LinePlan> lines, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new List<IReadOnlyList<Serial>>();
|
||||||
|
var documentSerials = new HashSet<int>();
|
||||||
|
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct);
|
||||||
|
result.Add(line.QtyDelta < 0
|
||||||
|
? await _serialTracking.ResolveOutboundAsync(item, warehouseId, -line.QtyDelta, line.SerialIds, documentSerials, WriteOffStatuses, ct)
|
||||||
|
: await _serialTracking.ResolveInboundAsync(item, warehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops serials a pending request's validation added, so they aren't created early.</summary>
|
||||||
|
private void DiscardUnsavedSerials()
|
||||||
|
{
|
||||||
|
foreach (var entry in _db.ChangeTracker.Entries<Serial>().Where(e => e.State == EntityState.Added).ToList())
|
||||||
|
entry.State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a saved adjustment to stock: re-resolves its serials, FIFO-consumes / adds layers,
|
||||||
|
/// writes the ledger, moves serials and posts the GL journal. Runs inside the caller's transaction;
|
||||||
|
/// throws (rolling everything back) if stock or serials no longer allow it.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<StockLedger>> PostInCurrentTransactionAsync(StockAdjustment entity, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var lines = entity.Lines.OrderBy(l => l.AdjLineId).ToList();
|
||||||
|
var plans = lines.Select(ToPlan).ToList();
|
||||||
|
var serialsByLine = await ResolveSerialsAsync(entity.WarehouseId, plans, ct);
|
||||||
|
for (var i = 0; i < lines.Count; i++)
|
||||||
|
lines[i].Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null;
|
||||||
|
await _uow.SaveChangesAsync(ct); // new serials get their ids
|
||||||
|
|
||||||
|
var deltas = plans.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
|
||||||
|
serialsByLine[i].Select(s => s.SerialId).ToList())).ToList();
|
||||||
|
var refs = await _mutator.ApplyAsync(entity.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, ct);
|
||||||
|
|
||||||
|
for (var i = 0; i < plans.Count; i++)
|
||||||
|
{
|
||||||
|
var line = plans[i];
|
||||||
|
await _serialTracking.MoveAsync(serialsByLine[i],
|
||||||
|
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
|
||||||
|
line.QtyDelta < 0 ? null : entity.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
|
||||||
|
DocumentTypes.Adjustment, entity.AdjustmentId, now, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.GlJournalNo = await PostAdjustmentJournalAsync(entity.DocNo, now, refs, ct);
|
||||||
|
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
|
||||||
|
entity.PostedAt = now;
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Average FIFO cost per unit at the warehouse — prices the request for the approval threshold.</summary>
|
||||||
|
private async Task<decimal> UnitValueAsync(int itemId, int warehouseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||||
|
return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<StockAdjustment> LoadPendingAsync(int adjustmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var adjustment = await _adjustments.Query().Include(a => a.Lines)
|
||||||
|
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct)
|
||||||
|
?? throw new NotFoundException($"Stock adjustment {adjustmentId} was not found.");
|
||||||
|
if (adjustment.Status is not (AdjustmentStatus.PendingApproval or AdjustmentStatus.PendingSecondApproval))
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
|
||||||
|
return adjustment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveDecisionAsync(StockAdjustment adjustment, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? PendingLevel(StockAdjustment a) => a.Status switch
|
||||||
|
{
|
||||||
|
AdjustmentStatus.PendingApproval => 1,
|
||||||
|
AdjustmentStatus.PendingSecondApproval => 2,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ApprovalTarget Target(StockAdjustment a) => new(
|
||||||
|
ApprovalDocType, a.AdjustmentId, a.DocNo, ApprovalNavCode, a.CreatedBy, a.RequiredApprovals,
|
||||||
|
PendingLevel(a) ?? 1, a.ApprovedLevel1By);
|
||||||
|
|
||||||
|
private async Task<string> SummaryAsync(StockAdjustment a, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var warehouse = await _warehouses.Query().AsNoTracking().Where(w => w.WarehouseId == a.WarehouseId).Select(w => w.Code).FirstOrDefaultAsync(ct);
|
||||||
|
var reason = await _reasonCodes.Query().AsNoTracking().Where(r => r.ReasonCodeId == a.ReasonCodeId).Select(r => r.Description).FirstOrDefaultAsync(ct);
|
||||||
|
return $"Stock adjustment {a.DocNo} ({a.Lines.Count} line{(a.Lines.Count == 1 ? "" : "s")}, {warehouse}, {reason})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AdjustmentDto> ToDtoAsync(StockAdjustment a, CancellationToken ct, IReadOnlyList<int>? ledgerRefs = null)
|
||||||
|
{
|
||||||
|
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
|
||||||
|
// so the refs this adjustment posted are recovered by source-doc lookup.
|
||||||
|
ledgerRefs ??= await _ledger.Query().AsNoTracking()
|
||||||
|
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == a.AdjustmentId)
|
||||||
|
.OrderBy(l => l.LedgerId)
|
||||||
|
.Select(l => l.LedgerId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var pending = PendingLevel(a);
|
||||||
|
var whyNot = pending is null ? "Not waiting for approval." : await _approvals.WhyCannotDecideAsync(Target(a), ct);
|
||||||
|
|
||||||
|
return new AdjustmentDto(
|
||||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
||||||
a.GlJournalNo, a.GlPostedAt,
|
a.GlJournalNo, a.GlPostedAt,
|
||||||
a.Lines.OrderBy(l => l.AdjLineId)
|
a.Lines.OrderBy(l => l.AdjLineId).Select(l =>
|
||||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId)).ToList(),
|
{
|
||||||
ledgerRefs);
|
var plan = ToPlan(l);
|
||||||
|
return new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId, plan.SerialIds, plan.SerialNos);
|
||||||
|
}).ToList(),
|
||||||
|
ledgerRefs,
|
||||||
|
a.RequiredApprovals, pending,
|
||||||
|
a.ApprovedLevel1By, a.ApprovedLevel1At, a.ApprovedLevel2By, a.ApprovedLevel2At,
|
||||||
|
a.RejectedBy, a.RejectedAt, a.RejectReason, a.PostedAt,
|
||||||
|
await _approvals.HistoryAsync(ApprovalDocType, a.AdjustmentId, ct),
|
||||||
|
whyNot is null, whyNot,
|
||||||
|
pending is not null && a.CreatedBy == _currentUser.AuditUserId,
|
||||||
|
await _db.Users.AsNoTracking().Where(u => u.UserId == a.CreatedBy).Select(u => u.DisplayName).FirstOrDefaultAsync(ct));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Approvals inbox (docs/DUAL_APPROVAL_PLAN.md §10): every pending stock and price adjustment
|
||||||
|
/// the current user can approve, plus their own requests (shown as waiting for others). A branch
|
||||||
|
/// user only sees their branch's stock adjustments; price changes are company-wide.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApprovalInboxService
|
||||||
|
{
|
||||||
|
private readonly IRepository<StockAdjustment> _stock;
|
||||||
|
private readonly IRepository<PriceAdjustment> _prices;
|
||||||
|
private readonly IApprovalService _approvals;
|
||||||
|
private readonly INavPermissionService _nav;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly ICurrentBranchContext _branch;
|
||||||
|
|
||||||
|
public ApprovalInboxService(
|
||||||
|
IRepository<StockAdjustment> stock, IRepository<PriceAdjustment> prices, IApprovalService approvals,
|
||||||
|
INavPermissionService nav, ICurrentUser currentUser, ICurrentBranchContext branch)
|
||||||
|
{
|
||||||
|
_stock = stock;
|
||||||
|
_prices = prices;
|
||||||
|
_approvals = approvals;
|
||||||
|
_nav = nav;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_branch = branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PendingApprovalsDto> PendingAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var me = _currentUser.AuditUserId;
|
||||||
|
var canStock = await CanApproveScreenAsync(AdjustmentService.ApprovalNavCode, ct);
|
||||||
|
var canPrice = await CanApproveScreenAsync(PriceAdjustmentService.ApprovalNavCode, ct);
|
||||||
|
var items = new List<PendingApprovalDto>();
|
||||||
|
|
||||||
|
var stockQuery = _stock.Query().AsNoTracking()
|
||||||
|
.Where(a => a.Status == AdjustmentStatus.PendingApproval || a.Status == AdjustmentStatus.PendingSecondApproval)
|
||||||
|
.Where(a => canStock || a.CreatedBy == me);
|
||||||
|
if (_branch.IsActive) stockQuery = stockQuery.Where(a => a.BranchId == _branch.BranchId);
|
||||||
|
var stock = await stockQuery
|
||||||
|
.Select(a => new
|
||||||
|
{
|
||||||
|
a.AdjustmentId, a.DocNo, a.Status, a.RequiredApprovals, a.CreatedBy, a.CreatedAt, a.ApprovedLevel1By,
|
||||||
|
RequestedByName = a.Creator!.DisplayName, Warehouse = a.Warehouse!.Code, Reason = a.ReasonCode!.Description, Lines = a.Lines.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var a in stock)
|
||||||
|
{
|
||||||
|
var level = a.Status == AdjustmentStatus.PendingApproval ? 1 : 2;
|
||||||
|
var target = new ApprovalTarget(AdjustmentService.ApprovalDocType, a.AdjustmentId, a.DocNo, AdjustmentService.ApprovalNavCode,
|
||||||
|
a.CreatedBy, a.RequiredApprovals, level, a.ApprovedLevel1By);
|
||||||
|
var why = canStock ? await _approvals.WhyCannotDecideAsync(target, ct) : "Waiting for an approver.";
|
||||||
|
items.Add(new PendingApprovalDto(AdjustmentService.ApprovalDocType, a.AdjustmentId, a.DocNo,
|
||||||
|
$"Stock adjustment — {a.Lines} line{(a.Lines == 1 ? "" : "s")}, {a.Warehouse}, {a.Reason}",
|
||||||
|
level, a.RequiredApprovals, a.CreatedBy, a.RequestedByName, a.CreatedAt, why is null, why));
|
||||||
|
}
|
||||||
|
|
||||||
|
var prices = await _prices.Query().AsNoTracking()
|
||||||
|
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
|
||||||
|
.Where(p => canPrice || p.CreatedBy == me)
|
||||||
|
.Select(p => new
|
||||||
|
{
|
||||||
|
p.PriceAdjustmentId, p.DocNo, p.Status, p.RequiredApprovals, p.CreatedBy, p.CreatedAt, p.ApprovedLevel1By,
|
||||||
|
RequestedByName = p.Creator!.DisplayName, p.Reason, Lines = p.Lines.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var p in prices)
|
||||||
|
{
|
||||||
|
var level = p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : 2;
|
||||||
|
var target = new ApprovalTarget(PriceAdjustmentService.ApprovalDocType, p.PriceAdjustmentId, p.DocNo, PriceAdjustmentService.ApprovalNavCode,
|
||||||
|
p.CreatedBy, p.RequiredApprovals, level, p.ApprovedLevel1By);
|
||||||
|
var why = canPrice ? await _approvals.WhyCannotDecideAsync(target, ct) : "Waiting for an approver.";
|
||||||
|
items.Add(new PendingApprovalDto(PriceAdjustmentService.ApprovalDocType, p.PriceAdjustmentId, p.DocNo,
|
||||||
|
$"Price adjustment — {p.Lines} item{(p.Lines == 1 ? "" : "s")}: {p.Reason}",
|
||||||
|
level, p.RequiredApprovals, p.CreatedBy, p.RequestedByName, p.CreatedAt, why is null, why));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actionable first, then oldest first — the longest-waiting request is what needs attention.
|
||||||
|
var ordered = items.OrderByDescending(i => i.CanApprove).ThenBy(i => i.RequestedAt).ToList();
|
||||||
|
return new PendingApprovalsDto(ordered, ordered.Count(i => i.CanApprove));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanApproveScreenAsync(string navCode, CancellationToken ct)
|
||||||
|
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleCode, navCode, RolePermissionAction.Approve, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared approval engine for stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §4). Holds the
|
||||||
|
/// maker–checker rules in one place; the document services call <see cref="DecideAsync"/> and apply
|
||||||
|
/// their effect only on <see cref="ApprovalOutcome.FullyApproved"/>. There is no Admin bypass of the
|
||||||
|
/// two-person rule — an Admin can approve, but never their own request and never both levels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApprovalService : IApprovalService
|
||||||
|
{
|
||||||
|
private const string AdminRoleCode = "Admin";
|
||||||
|
|
||||||
|
private readonly IRepository<ApprovalConfig> _config;
|
||||||
|
private readonly IRepository<DocumentApproval> _approvals;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
|
private readonly IRepository<RolePermission> _rolePermissions;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly INotificationService _notifications;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public ApprovalService(
|
||||||
|
IRepository<ApprovalConfig> config, IRepository<DocumentApproval> approvals, IRepository<User> users,
|
||||||
|
IRepository<RolePermission> rolePermissions, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_approvals = approvals;
|
||||||
|
_users = users;
|
||||||
|
_rolePermissions = rolePermissions;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_notifications = notifications;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApprovalConfigDto> GetConfigAsync(CancellationToken ct = default) => Map(await LoadConfigAsync(ct));
|
||||||
|
|
||||||
|
public async Task<ApprovalConfigDto> UpdateConfigAsync(UpdateApprovalConfigRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var config = await _config.Query().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct);
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
config = new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
|
||||||
|
await _config.AddAsync(config, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.StockAdjustmentLevels = Math.Clamp(request.StockAdjustmentLevels, 0, 2);
|
||||||
|
config.StockAdjustmentLevel2ThresholdValue = Math.Max(0m, request.StockAdjustmentLevel2ThresholdValue);
|
||||||
|
config.PriceAdjustmentLevels = Math.Clamp(request.PriceAdjustmentLevels, 0, 2);
|
||||||
|
config.PriceAdjustmentLevel2ThresholdPercent = Math.Max(0m, request.PriceAdjustmentLevel2ThresholdPercent);
|
||||||
|
var roles = request.Level2RoleCodes.Select(r => r.Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
config.Level2RoleCodes = roles.Count == 0 ? null : string.Join(",", roles);
|
||||||
|
config.UpdatedAt = DateTime.UtcNow;
|
||||||
|
config.UpdatedBy = _currentUser.AuditUserId;
|
||||||
|
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
return Map(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RequiredForStockAdjustmentAsync(decimal absoluteValue, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var c = await LoadConfigAsync(ct);
|
||||||
|
return Levels(c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue, absoluteValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var c = await LoadConfigAsync(ct);
|
||||||
|
return Levels(c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent, largestChangePercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Two levels drop to one below a positive threshold; 0 and 1 are taken as configured.</summary>
|
||||||
|
public static int Levels(int configured, decimal threshold, decimal measure)
|
||||||
|
=> configured >= 2 && threshold > 0 && measure < threshold ? 1 : Math.Clamp(configured, 0, 2);
|
||||||
|
|
||||||
|
public async Task<ApprovalOutcome> DecideAsync(ApprovalTarget target, bool approve, string? comment, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (why, code) = await CheckAsync(target, ct);
|
||||||
|
if (why is not null) throw new DomainException(code!, why, 403);
|
||||||
|
|
||||||
|
var note = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
|
||||||
|
if (!approve && note is null)
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalReasonRequired, "Give a reason for rejecting.", 400);
|
||||||
|
|
||||||
|
await _approvals.AddAsync(new DocumentApproval
|
||||||
|
{
|
||||||
|
DocType = target.DocType,
|
||||||
|
DocId = target.DocId,
|
||||||
|
Level = target.CurrentLevel,
|
||||||
|
Decision = approve ? ApprovalDecision.Approved : ApprovalDecision.Rejected,
|
||||||
|
DecidedBy = _currentUser.AuditUserId,
|
||||||
|
DecidedAt = DateTime.UtcNow,
|
||||||
|
Comment = note
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
if (!approve) return ApprovalOutcome.Rejected;
|
||||||
|
return target.CurrentLevel >= target.RequiredApprovals ? ApprovalOutcome.FullyApproved : ApprovalOutcome.AdvancedToLevel2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> WhyCannotDecideAsync(ApprovalTarget target, CancellationToken ct = default)
|
||||||
|
=> (await CheckAsync(target, ct)).Why;
|
||||||
|
|
||||||
|
private async Task<(string? Why, string? Code)> CheckAsync(ApprovalTarget target, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var actor = _currentUser.AuditUserId;
|
||||||
|
if (actor == target.RequestedBy)
|
||||||
|
return ("You made this request — someone else has to approve it.", ErrorCodes.ApprovalForbidden);
|
||||||
|
if (target.CurrentLevel >= 2 && target.Level1ApprovedBy == actor)
|
||||||
|
return ("You gave the first approval — a different person has to give the second.", ErrorCodes.ApprovalSamePerson);
|
||||||
|
if (target.CurrentLevel >= 2)
|
||||||
|
{
|
||||||
|
var roles = Level2Roles(await LoadConfigAsync(ct));
|
||||||
|
if (roles.Count > 0 && !_currentUser.IsAdmin && !roles.Contains(_currentUser.RoleCode ?? string.Empty))
|
||||||
|
return ($"Only {string.Join(", ", roles)} can give the second approval.", ErrorCodes.ApprovalRoleRequired);
|
||||||
|
}
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct = default)
|
||||||
|
=> await _approvals.Query().AsNoTracking()
|
||||||
|
.Where(a => a.DocType == docType && a.DocId == docId)
|
||||||
|
.OrderBy(a => a.DocumentApprovalId)
|
||||||
|
.Select(a => new ApprovalStepDto(a.Level, a.Decision, a.DecidedBy, a.Decider!.DisplayName, a.DecidedAt, a.Comment))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<int>> ApproverIdsAsync(ApprovalTarget target, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var permissionCode = "NAV:" + target.NavCode;
|
||||||
|
var grants = await _rolePermissions.Query().AsNoTracking()
|
||||||
|
.Where(rp => rp.Permission!.Code == permissionCode)
|
||||||
|
.Select(rp => new { rp.RoleId, rp.Actions })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var approverRoleIds = grants.Where(g => g.Actions.HasFlag(RolePermissionAction.Approve)).Select(g => g.RoleId).ToHashSet();
|
||||||
|
|
||||||
|
var candidates = await _users.Query().AsNoTracking()
|
||||||
|
.Where(u => u.Status == EntityStatus.Active && u.RoleId != null)
|
||||||
|
.Select(u => new { u.UserId, RoleId = u.RoleId!.Value, RoleCode = u.Role!.Code })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var level2Roles = target.CurrentLevel >= 2 ? Level2Roles(await LoadConfigAsync(ct)) : [];
|
||||||
|
return candidates
|
||||||
|
.Where(u => string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase) || approverRoleIds.Contains(u.RoleId))
|
||||||
|
.Where(u => u.UserId != target.RequestedBy && u.UserId != target.Level1ApprovedBy)
|
||||||
|
.Where(u => level2Roles.Count == 0 || level2Roles.Contains(u.RoleCode) || string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(u => u.UserId)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyPendingAsync(ApprovalTarget target, string summary, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var approvers = await ApproverIdsAsync(target, ct);
|
||||||
|
var step = target.RequiredApprovals > 1 ? $" (approval {target.CurrentLevel} of {target.RequiredApprovals})" : string.Empty;
|
||||||
|
await _notifications.NotifyUsersAsync(approvers, "approval.pending",
|
||||||
|
$"Approval needed: {target.DocNo}", $"{summary} is waiting for your approval{step}.", target.DocType, target.DocId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApprovalConfig> LoadConfigAsync(CancellationToken ct)
|
||||||
|
=> await _config.Query().AsNoTracking().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct)
|
||||||
|
?? new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
|
||||||
|
|
||||||
|
private static HashSet<string> Level2Roles(ApprovalConfig c)
|
||||||
|
=> (c.Level2RoleCodes ?? string.Empty)
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static ApprovalConfigDto Map(ApprovalConfig c) => new(
|
||||||
|
c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue,
|
||||||
|
c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent,
|
||||||
|
Level2Roles(c).ToList(), c.UpdatedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Accounts;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Till cash taken to the bank (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2). Saving a deposit posts a
|
||||||
|
/// real GL entry (Debit the bank account / Credit the cash account) before anything is committed —
|
||||||
|
/// the same call-GL-before-commit pattern as <see cref="ExpensePaymentService"/>, so a GL rejection
|
||||||
|
/// rolls the deposit back. A deposit may not exceed the branch's cash in hand
|
||||||
|
/// (<see cref="ICashInHandService"/>); the check runs inside the transaction after locking the
|
||||||
|
/// branch row, so two cashiers can't both deposit the same cash. Cancelling posts the reversing entry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BankDepositService : IBankDepositService
|
||||||
|
{
|
||||||
|
private const string SourceModule = "BANK_DEPOSIT";
|
||||||
|
|
||||||
|
private readonly IRepository<BankDeposit> _deposits;
|
||||||
|
private readonly IRepository<Branch> _branches;
|
||||||
|
private readonly ICashInHandService _cash;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly ICurrentBranchContext _currentBranch;
|
||||||
|
private readonly INumberSequenceService _numbers;
|
||||||
|
private readonly IBranchConfigService _branchConfig;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly ErpDbContext _db;
|
||||||
|
private readonly BusinessDayClock _clock;
|
||||||
|
|
||||||
|
public BankDepositService(
|
||||||
|
IRepository<BankDeposit> deposits, IRepository<Branch> branches, ICashInHandService cash,
|
||||||
|
IGeneralLedgerService gl, ICurrentUser currentUser, ICurrentBranchContext currentBranch,
|
||||||
|
INumberSequenceService numbers, IBranchConfigService branchConfig, IUnitOfWork uow,
|
||||||
|
ErpDbContext db, BusinessDayClock clock)
|
||||||
|
{
|
||||||
|
_deposits = deposits;
|
||||||
|
_branches = branches;
|
||||||
|
_cash = cash;
|
||||||
|
_gl = gl;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_currentBranch = currentBranch;
|
||||||
|
_numbers = numbers;
|
||||||
|
_branchConfig = branchConfig;
|
||||||
|
_uow = uow;
|
||||||
|
_db = db;
|
||||||
|
_clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BankDepositListDto> ListAsync(PageQuery query, BankDepositListFilter filter, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<BankDeposit> q = _deposits.Query().AsNoTracking().Include(x => x.Branch);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|
||||||
|
|| (x.DepositSlipNo != null && EF.Functions.Like(x.DepositSlipNo, $"%{term}%"))
|
||||||
|
|| (x.DepositedBy != null && EF.Functions.Like(x.DepositedBy, $"%{term}%")));
|
||||||
|
}
|
||||||
|
if (filter.From is not null) q = q.Where(x => x.BusinessDate >= filter.From);
|
||||||
|
if (filter.To is not null) q = q.Where(x => x.BusinessDate <= filter.To);
|
||||||
|
if (filter.BranchId is not null) q = q.Where(x => x.BranchId == filter.BranchId);
|
||||||
|
if (filter.BankAccountId is not null) q = q.Where(x => x.ToGlBankAccountId == filter.BankAccountId);
|
||||||
|
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var totalAmount = await q.SumAsync(x => x.Amount, ct);
|
||||||
|
var rows = await q.OrderByDescending(x => x.BusinessDate).ThenByDescending(x => x.BankDepositId)
|
||||||
|
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
|
||||||
|
var page = PagedResponse<BankDepositDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||||
|
return new BankDepositListDto(page.Items, page.Pagination, totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BankDepositDto?> GetAsync(int bankDepositId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var deposit = await _deposits.Query().AsNoTracking().Include(x => x.Branch)
|
||||||
|
.FirstOrDefaultAsync(x => x.BankDepositId == bankDepositId, ct);
|
||||||
|
return deposit is null ? null : Map(deposit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BankDepositDto> CreateAsync(CreateBankDepositRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request.Amount <= 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "The deposit amount must be greater than zero.", 400);
|
||||||
|
|
||||||
|
var (branchId, cashScope) = await ResolveBranchAsync(request.BranchId, ct);
|
||||||
|
var branch = await _branches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BranchId == branchId, ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"Branch {branchId} does not exist.", 422);
|
||||||
|
|
||||||
|
var depositDate = ValidateDates(request);
|
||||||
|
var (cashAccount, bankAccount) = await ResolveAccountsAsync(request, ct);
|
||||||
|
|
||||||
|
var deposit = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
await LockBranchAsync(branchId, token);
|
||||||
|
|
||||||
|
// Re-read inside the lock: a deposit committed a moment ago by someone else is now visible.
|
||||||
|
var available = await _cash.GetAvailableToDepositAsync(request.BusinessDate, cashScope, token);
|
||||||
|
if (request.Amount > available)
|
||||||
|
throw new DomainException(ErrorCodes.BankDepositExceedsCashInHand,
|
||||||
|
$"Amount {request.Amount:N2} exceeds the cash in hand available for {request.BusinessDate:yyyy-MM-dd} ({available:N2}).", 400);
|
||||||
|
|
||||||
|
var docNo = await _numbers.NextAsync(DocumentTypes.BankDeposit, branchId, token);
|
||||||
|
var narration = $"Cash deposit {docNo} — {request.BusinessDate:yyyy-MM-dd}";
|
||||||
|
var period = await _gl.GetPeriodByDateAsync(depositDate, token);
|
||||||
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
PeriodId = period.PeriodId,
|
||||||
|
EntryDate = depositDate,
|
||||||
|
SourceModule = SourceModule,
|
||||||
|
Reference = docNo,
|
||||||
|
Narration = narration,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new GlJournalEntryLineRequest(bankAccount.GlAccountCode, request.Amount, 0m, narration),
|
||||||
|
new GlJournalEntryLineRequest(cashAccount.GlAccountCode, 0m, request.Amount, narration)
|
||||||
|
]
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var entity = new BankDeposit
|
||||||
|
{
|
||||||
|
DocNo = docNo,
|
||||||
|
BranchId = branchId,
|
||||||
|
BusinessDate = request.BusinessDate,
|
||||||
|
DepositDate = depositDate,
|
||||||
|
Amount = request.Amount,
|
||||||
|
FromGlCashAccountId = cashAccount.AccountId,
|
||||||
|
FromCashAccountCode = cashAccount.GlAccountCode,
|
||||||
|
FromCashAccountName = cashAccount.AccountName,
|
||||||
|
ToGlBankAccountId = bankAccount.AccountId,
|
||||||
|
ToBankAccountCode = bankAccount.GlAccountCode,
|
||||||
|
ToBankAccountName = bankAccount.AccountName,
|
||||||
|
ToBankName = bankAccount.BankName,
|
||||||
|
ToAccountNumber = bankAccount.AccountNumber,
|
||||||
|
DepositSlipNo = Normalize(request.DepositSlipNo),
|
||||||
|
DepositedBy = Normalize(request.DepositedBy),
|
||||||
|
Remarks = Normalize(request.Remarks),
|
||||||
|
Status = BankDepositStatus.Posted,
|
||||||
|
GlJournalNo = posted.JournalNo,
|
||||||
|
GlPostedAt = now,
|
||||||
|
CreatedBy = _currentUser.AuditUserId,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
await _deposits.AddAsync(entity, token);
|
||||||
|
return entity;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return Map(deposit, branch.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BankDepositDto> CancelAsync(int bankDepositId, CancelBankDepositRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var deposit = await _deposits.Query().Include(x => x.Branch)
|
||||||
|
.FirstOrDefaultAsync(x => x.BankDepositId == bankDepositId, ct)
|
||||||
|
?? throw new NotFoundException($"Bank deposit {bankDepositId} was not found.");
|
||||||
|
|
||||||
|
if (deposit.Status != BankDepositStatus.Posted)
|
||||||
|
throw new DomainException(ErrorCodes.BankDepositNotCancellable, $"Bank deposit {deposit.DocNo} is already {deposit.Status}.", 409);
|
||||||
|
|
||||||
|
// Cancelling only puts cash back in hand, so there is no balance to check.
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
var today = _clock.Today;
|
||||||
|
var narration = $"Cancellation of cash deposit {deposit.DocNo}";
|
||||||
|
var period = await _gl.GetPeriodByDateAsync(today, token);
|
||||||
|
var reversal = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
PeriodId = period.PeriodId,
|
||||||
|
EntryDate = today,
|
||||||
|
SourceModule = SourceModule,
|
||||||
|
Reference = deposit.DocNo,
|
||||||
|
Narration = narration,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new GlJournalEntryLineRequest(deposit.FromCashAccountCode, deposit.Amount, 0m, narration),
|
||||||
|
new GlJournalEntryLineRequest(deposit.ToBankAccountCode, 0m, deposit.Amount, narration)
|
||||||
|
]
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
deposit.Status = BankDepositStatus.Cancelled;
|
||||||
|
deposit.GlReversalJournalNo = reversal.JournalNo;
|
||||||
|
deposit.CancelReason = request.Reason.Trim();
|
||||||
|
deposit.CancelledBy = _currentUser.AuditUserId;
|
||||||
|
deposit.CancelledAt = now;
|
||||||
|
return 0;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return Map(deposit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The branch the deposit belongs to and the branch scope its cash in hand is measured over. A
|
||||||
|
/// branch user always deposits for their own branch. With branching off every document lives on
|
||||||
|
/// the one default branch, so the cash is measured across all of them (scope null).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(int BranchId, int? CashScope)> ResolveBranchAsync(int requested, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_currentBranch.IsActive && _currentBranch.BranchId is { } own)
|
||||||
|
return (own, own);
|
||||||
|
|
||||||
|
var branchId = await _branchConfig.ResolveBranchIdAsync(requested, ct);
|
||||||
|
return await _branchConfig.IsBranchingEnabledAsync(ct) ? (branchId, branchId) : (branchId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateOnly ValidateDates(CreateBankDepositRequest request)
|
||||||
|
{
|
||||||
|
var today = _clock.Today;
|
||||||
|
if (request.BusinessDate > today)
|
||||||
|
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The business date cannot be in the future.", 400);
|
||||||
|
if (_cash.TrackingStartDate is { } start && request.BusinessDate < start)
|
||||||
|
throw new DomainException(ErrorCodes.BankDepositDateInvalid,
|
||||||
|
$"Cash in hand is tracked from {start:yyyy-MM-dd}; earlier days cannot be deposited.", 400);
|
||||||
|
|
||||||
|
var depositDate = request.DepositDate ?? request.BusinessDate;
|
||||||
|
if (depositDate < request.BusinessDate)
|
||||||
|
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The deposit date cannot be before the business date.", 400);
|
||||||
|
// One day of slack, as with payment dates: the client sends its local date.
|
||||||
|
if (depositDate > today.AddDays(1))
|
||||||
|
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The deposit date cannot be in the future.", 400);
|
||||||
|
return depositDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(GlBankAccount Cash, GlBankAccount Bank)> ResolveAccountsAsync(CreateBankDepositRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||||
|
|
||||||
|
// GL keeps cash and bank accounts in separate tables with their own ids, so cash account 1 and
|
||||||
|
// bank account 1 can both exist — an id only means something together with its type.
|
||||||
|
var cash = accounts.FirstOrDefault(a => a.AccountId == request.FromGlCashAccountId && IsType(a, "Cash"))
|
||||||
|
?? throw new DomainException(ErrorCodes.BankDepositCashAccountInvalid,
|
||||||
|
$"Cash account {request.FromGlCashAccountId} was not found — deposits must come out of a cash account.", 400);
|
||||||
|
|
||||||
|
var bank = accounts.FirstOrDefault(a => a.AccountId == request.ToGlBankAccountId && IsType(a, "Bank"));
|
||||||
|
if (bank is null)
|
||||||
|
throw new DomainException(ErrorCodes.BankDepositBankAccountInvalid,
|
||||||
|
$"Bank account {request.ToGlBankAccountId} was not found — deposits must go into a bank account.", 400);
|
||||||
|
|
||||||
|
return (cash, bank);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsType(GlBankAccount account, string type)
|
||||||
|
=> string.Equals(account.AccountType, type, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes deposits per branch for the rest of the transaction: the second of two concurrent
|
||||||
|
/// deposits waits here, then sees the first one's committed row when it re-reads the balance.
|
||||||
|
/// The in-memory test provider has no row locks (or concurrency), so it is skipped there.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LockBranchAsync(int branchId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_db.Database.IsRelational()) return;
|
||||||
|
await _db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$"SELECT `BranchId` FROM `hr_branches` WHERE `BranchId` = {branchId} FOR UPDATE", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
/// <summary><paramref name="branchName"/> stands in when the Branch navigation isn't loaded (just created).</summary>
|
||||||
|
private static BankDepositDto Map(BankDeposit x) => Map(x, x.Branch?.Name);
|
||||||
|
|
||||||
|
private static BankDepositDto Map(BankDeposit x, string? branchName) => new(
|
||||||
|
x.BankDepositId, x.DocNo, x.BranchId, branchName,
|
||||||
|
x.BusinessDate, x.DepositDate, x.Amount,
|
||||||
|
x.FromGlCashAccountId, x.FromCashAccountCode, x.FromCashAccountName,
|
||||||
|
x.ToGlBankAccountId, x.ToBankAccountCode, x.ToBankAccountName, x.ToBankName, x.ToAccountNumber,
|
||||||
|
x.DepositSlipNo, x.DepositedBy, x.Remarks,
|
||||||
|
x.Status, x.GlJournalNo, x.GlPostedAt,
|
||||||
|
x.GlReversalJournalNo, x.CancelReason, x.CancelledAt,
|
||||||
|
x.CreatedBy, x.CreatedAt);
|
||||||
|
}
|
||||||
@@ -178,7 +178,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
};
|
};
|
||||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
|
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
|
||||||
Recalculate(bundle, request.BundlePrice);
|
Recalculate(bundle, request.BundlePrice, request.CardAmount);
|
||||||
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
||||||
await _bundles.AddAsync(bundle, ct);
|
await _bundles.AddAsync(bundle, ct);
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
@@ -203,7 +203,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
bundle.Lines.Clear();
|
bundle.Lines.Clear();
|
||||||
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
|
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
|
||||||
Recalculate(bundle, request.BundlePrice);
|
Recalculate(bundle, request.BundlePrice, request.CardAmount);
|
||||||
bundle.UpdatedAt = DateTime.UtcNow;
|
bundle.UpdatedAt = DateTime.UtcNow;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return Map(bundle);
|
return Map(bundle);
|
||||||
@@ -273,7 +273,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Recalculate(BundleSale bundle, decimal bundlePrice)
|
/// <summary>A bundle sale is always fully paid; <paramref name="cardAmount"/> null means all cash.</summary>
|
||||||
|
private static void Recalculate(BundleSale bundle, decimal bundlePrice, decimal? cardAmount)
|
||||||
{
|
{
|
||||||
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
|
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
|
||||||
bundle.BundlePrice = bundlePrice;
|
bundle.BundlePrice = bundlePrice;
|
||||||
@@ -281,6 +282,13 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
|
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
|
||||||
bundle.TaxTotal = 0m;
|
bundle.TaxTotal = 0m;
|
||||||
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
|
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
|
||||||
|
|
||||||
|
var card = cardAmount ?? 0m;
|
||||||
|
if (card < 0 || card > bundle.GrandTotal)
|
||||||
|
throw new DomainException(ErrorCodes.SalesCardAmountInvalid,
|
||||||
|
$"cardAmount must be between 0 and the bundle's total {bundle.GrandTotal}.", 400);
|
||||||
|
bundle.CardAmount = card;
|
||||||
|
bundle.CashAmount = bundle.GrandTotal - card;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
|
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
|
||||||
@@ -290,6 +298,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
|
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
|
||||||
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
|
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
|
||||||
x.CreatedAt, x.UpdatedAt,
|
x.CreatedAt, x.UpdatedAt,
|
||||||
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
|
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList(),
|
||||||
|
x.CashAmount, x.CardAmount);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The business calendar day shared by the Day-End Sales Report and the Bank Deposit module
|
||||||
|
/// (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.1). Sales documents are stamped in UTC, so a business day
|
||||||
|
/// is the <c>DayEndReport:TimeZone</c> calendar day converted to a UTC <c>[start, end)</c> window.
|
||||||
|
/// One instance per app — the zone is resolved once from configuration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BusinessDayClock
|
||||||
|
{
|
||||||
|
public BusinessDayClock(IConfiguration configuration)
|
||||||
|
=> Zone = ResolveZone(configuration["DayEndReport:TimeZone"]);
|
||||||
|
|
||||||
|
public TimeZoneInfo Zone { get; }
|
||||||
|
|
||||||
|
/// <summary>Today in the business time zone.</summary>
|
||||||
|
public DateOnly Today => LocalDay(DateTime.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>The UTC instant the local day starts at.</summary>
|
||||||
|
public DateTime ToUtc(DateOnly localDay)
|
||||||
|
=> TimeZoneInfo.ConvertTimeToUtc(localDay.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified), Zone);
|
||||||
|
|
||||||
|
/// <summary>The local business day a UTC timestamp falls on.</summary>
|
||||||
|
public DateOnly LocalDay(DateTime utc)
|
||||||
|
=> DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), Zone));
|
||||||
|
|
||||||
|
private static TimeZoneInfo ResolveZone(string? id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
|
||||||
|
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
|
||||||
|
catch (TimeZoneNotFoundException) { return TimeZoneInfo.Utc; }
|
||||||
|
catch (InvalidTimeZoneException) { return TimeZoneInfo.Utc; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Accounts;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Till cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §7.1). Only cash counts: slip and bundle
|
||||||
|
/// <c>CashAmount</c> and cash invoice payments come in, cash expense payments go out, and Posted bank
|
||||||
|
/// deposits take it to the bank. The balance carries from day to day starting at
|
||||||
|
/// <c>BankDeposit:CashTrackingStartDate</c>; the opening balance before a window is a handful of
|
||||||
|
/// database sums, and the days inside it are bucketed by local day here (same reason as
|
||||||
|
/// <see cref="DayEndReportService"/>: MariaDB can't portably group by a zone-shifted date).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CashInHandService : ICashInHandService
|
||||||
|
{
|
||||||
|
private const int MaxRangeDays = 92;
|
||||||
|
|
||||||
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
private readonly IRepository<BundleSale> _bundles;
|
||||||
|
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
|
||||||
|
private readonly IRepository<ExpensePayment> _expensePayments;
|
||||||
|
private readonly IRepository<BankDeposit> _deposits;
|
||||||
|
private readonly ICurrentBranchContext _currentBranch;
|
||||||
|
private readonly BusinessDayClock _clock;
|
||||||
|
private readonly string? _defaultCashAccountCode;
|
||||||
|
|
||||||
|
public CashInHandService(
|
||||||
|
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoicePayment> invoicePayments,
|
||||||
|
IRepository<ExpensePayment> expensePayments, IRepository<BankDeposit> deposits,
|
||||||
|
ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_slips = slips;
|
||||||
|
_bundles = bundles;
|
||||||
|
_invoicePayments = invoicePayments;
|
||||||
|
_expensePayments = expensePayments;
|
||||||
|
_deposits = deposits;
|
||||||
|
_currentBranch = currentBranch;
|
||||||
|
_clock = clock;
|
||||||
|
_defaultCashAccountCode = configuration["BankDeposit:DefaultCashAccountCode"];
|
||||||
|
TrackingStartDate = DateOnly.TryParse(configuration["BankDeposit:CashTrackingStartDate"], CultureInfo.InvariantCulture, out var start)
|
||||||
|
? start
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateOnly? TrackingStartDate { get; }
|
||||||
|
|
||||||
|
public async Task<CashInHandDto> GetPositionAsync(DateOnly? day, int? branchId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var date = day ?? _clock.Today;
|
||||||
|
var branch = EffectiveBranch(branchId);
|
||||||
|
var today = _clock.Today;
|
||||||
|
|
||||||
|
// One pass from the day to today serves both the day's own figures and what is still depositable.
|
||||||
|
var days = await ComputeAsync(date, date > today ? date : today, branch, ct);
|
||||||
|
return new CashInHandDto(days[0], Available(days, date, today), branch, TrackingStartDate, _clock.Zone.Id, _defaultCashAccountCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CashInHandDailyDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (to < from)
|
||||||
|
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, "'to' must be on or after 'from'.", 400);
|
||||||
|
if (to.DayNumber - from.DayNumber + 1 > MaxRangeDays)
|
||||||
|
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, $"The range can be at most {MaxRangeDays} days.", 400);
|
||||||
|
|
||||||
|
var branch = EffectiveBranch(branchId);
|
||||||
|
var days = await ComputeAsync(from, to, branch, ct);
|
||||||
|
return new CashInHandDailyDto(from, to, days, branch, TrackingStartDate, _clock.Zone.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CashPositionDto>> GetRangeAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
|
||||||
|
=> await ComputeAsync(from, to, EffectiveBranch(branchId), ct);
|
||||||
|
|
||||||
|
public async Task<decimal> GetAvailableToDepositAsync(DateOnly day, int? branchScope, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var today = _clock.Today;
|
||||||
|
if (day > today || !IsTracked(day)) return 0m;
|
||||||
|
return Available(await ComputeAsync(day, today, branchScope, ct), day, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A branch-scoped (non-admin) caller always sees only their own branch, whatever they ask for.</summary>
|
||||||
|
private int? EffectiveBranch(int? requested) => _currentBranch.IsActive ? _currentBranch.BranchId : requested;
|
||||||
|
|
||||||
|
private bool IsTracked(DateOnly day) => TrackingStartDate is not { } start || day >= start;
|
||||||
|
|
||||||
|
/// <summary>Lowest closing balance from <paramref name="day"/> to today: depositing more would overdraw a later day.</summary>
|
||||||
|
private decimal Available(IReadOnlyList<CashPositionDto> days, DateOnly day, DateOnly today)
|
||||||
|
{
|
||||||
|
if (day > today || !IsTracked(day)) return 0m;
|
||||||
|
var min = days.Where(d => d.Date >= day && d.Date <= today).Min(d => d.Closing);
|
||||||
|
return Math.Max(0m, min);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<CashPositionDto>> ComputeAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Opening: everything tracked before the window — [start, from).
|
||||||
|
var opening = 0m;
|
||||||
|
if (TrackingStartDate is not { } trackStart || trackStart < from)
|
||||||
|
{
|
||||||
|
DateTime? lo = TrackingStartDate is { } s ? _clock.ToUtc(s) : null;
|
||||||
|
var hi = _clock.ToUtc(from);
|
||||||
|
var cashIn = await Slips(lo, hi, branch).SumAsync(s => s.CashAmount, ct)
|
||||||
|
+ await Bundles(lo, hi, branch).SumAsync(b => b.CashAmount, ct)
|
||||||
|
+ await InvoicePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
|
||||||
|
var cashOut = await ExpensePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
|
||||||
|
var deposited = await Deposits(TrackingStartDate, from, branch).SumAsync(d => d.Amount, ct);
|
||||||
|
opening = cashIn - cashOut - deposited;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window itself, bucketed by local day.
|
||||||
|
var start = _clock.ToUtc(from);
|
||||||
|
var end = _clock.ToUtc(to.AddDays(1));
|
||||||
|
var inRows = new List<(DateTime At, decimal Amount)>();
|
||||||
|
inRows.AddRange((await Slips(start, end, branch).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct))
|
||||||
|
.Select(r => (r.SlipDate, r.CashAmount)));
|
||||||
|
inRows.AddRange((await Bundles(start, end, branch).Select(b => new { b.BundleDate, b.CashAmount }).ToListAsync(ct))
|
||||||
|
.Select(r => (r.BundleDate, r.CashAmount)));
|
||||||
|
inRows.AddRange((await InvoicePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct))
|
||||||
|
.Select(r => (r.PaymentDate, r.Amount)));
|
||||||
|
var outRows = await ExpensePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct);
|
||||||
|
var depositRows = await Deposits(from, to.AddDays(1), branch).Select(d => new { d.BusinessDate, d.Amount }).ToListAsync(ct);
|
||||||
|
|
||||||
|
var inByDay = inRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
|
||||||
|
var outByDay = outRows.GroupBy(r => _clock.LocalDay(r.PaymentDate)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
|
||||||
|
var depositByDay = depositRows.GroupBy(r => r.BusinessDate).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
|
||||||
|
|
||||||
|
var days = new List<CashPositionDto>(to.DayNumber - from.DayNumber + 1);
|
||||||
|
var running = opening;
|
||||||
|
for (var day = from; day <= to; day = day.AddDays(1))
|
||||||
|
{
|
||||||
|
var cashIn = inByDay.GetValueOrDefault(day);
|
||||||
|
var cashOut = outByDay.GetValueOrDefault(day);
|
||||||
|
var deposited = depositByDay.GetValueOrDefault(day);
|
||||||
|
var net = cashIn - cashOut;
|
||||||
|
|
||||||
|
// Before tracking starts nothing carries in or out; the day stands alone.
|
||||||
|
var tracked = IsTracked(day);
|
||||||
|
var open = tracked ? running : 0m;
|
||||||
|
var close = open + net - deposited;
|
||||||
|
if (tracked) running = close;
|
||||||
|
|
||||||
|
days.Add(new CashPositionDto(day, open, cashIn, cashOut, net, deposited, close));
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sources. lo is inclusive (null = no lower bound), hi exclusive. ---
|
||||||
|
|
||||||
|
private IQueryable<SalesSlip> Slips(DateTime? lo, DateTime hi, int? branch)
|
||||||
|
{
|
||||||
|
var q = _slips.Query().AsNoTracking()
|
||||||
|
.Where(s => s.Status == SalesSlipStatus.Posted && s.CashAmount != 0 && s.SlipDate < hi);
|
||||||
|
if (lo is not null) q = q.Where(s => s.SlipDate >= lo);
|
||||||
|
return branch is null ? q : q.Where(s => s.Warehouse!.BranchId == branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<BundleSale> Bundles(DateTime? lo, DateTime hi, int? branch)
|
||||||
|
{
|
||||||
|
var q = _bundles.Query().AsNoTracking()
|
||||||
|
.Where(b => b.Status == BundleSaleStatus.Posted && b.CashAmount != 0 && b.BundleDate < hi);
|
||||||
|
if (lo is not null) q = q.Where(b => b.BundleDate >= lo);
|
||||||
|
return branch is null ? q : q.Where(b => b.Warehouse!.BranchId == branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cash received against Posted invoices — whatever day the invoice itself is from.</summary>
|
||||||
|
private IQueryable<SalesInvoicePayment> InvoicePayments(DateTime? lo, DateTime hi, int? branch)
|
||||||
|
{
|
||||||
|
var q = _invoicePayments.Query().AsNoTracking()
|
||||||
|
.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash && p.SalesInvoice!.Status == SalesInvoiceStatus.Posted && p.PaymentDate < hi);
|
||||||
|
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
|
||||||
|
return branch is null ? q : q.Where(p => p.SalesInvoice!.BranchId == branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<ExpensePayment> ExpensePayments(DateTime? lo, DateTime hi, int? branch)
|
||||||
|
{
|
||||||
|
var q = _expensePayments.Query().AsNoTracking()
|
||||||
|
.Where(p => p.PaymentMethod == ExpensePaymentMethod.Cash && p.Expense!.Status != ExpenseStatus.Cancelled && p.PaymentDate < hi);
|
||||||
|
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
|
||||||
|
return branch is null ? q : q.Where(p => p.Expense!.BranchId == branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Posted deposits by business date; <paramref name="lo"/> inclusive (null = no bound), <paramref name="hi"/> exclusive.</summary>
|
||||||
|
private IQueryable<BankDeposit> Deposits(DateOnly? lo, DateOnly hi, int? branch)
|
||||||
|
{
|
||||||
|
var q = _deposits.Query().AsNoTracking()
|
||||||
|
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate < hi);
|
||||||
|
if (lo is not null) q = q.Where(d => d.BusinessDate >= lo);
|
||||||
|
return branch is null ? q : q.Where(d => d.BranchId == branch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,53 +16,47 @@ namespace ERPCore.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
|
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
|
||||||
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
|
/// 02-SECURITY C.7); posting closes the count and hands its variances to
|
||||||
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
|
/// <see cref="AdjustmentService"/> as one adjustment (a variance is an adjustment in disguise,
|
||||||
/// and closes the count — all in one UoW transaction. GL posting for that variance
|
/// C.7). That adjustment follows the same approval rules as any other
|
||||||
/// mirrors <see cref="AdjustmentService"/>'s (same account config, same gain/loss
|
/// (docs/DUAL_APPROVAL_PLAN.md §8, decision 3) — a count is not a way around approval — and
|
||||||
/// split) since it's the exact same kind of document under the hood.
|
/// moves stock when it is approved, or at once when approvals are switched off.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CountService : ICountService
|
public sealed class CountService : ICountService
|
||||||
{
|
{
|
||||||
private const string VarianceReasonCode = "VAR";
|
private const string VarianceReasonCode = "VAR";
|
||||||
|
|
||||||
private readonly IRepository<StockCount> _counts;
|
private readonly IRepository<StockCount> _counts;
|
||||||
private readonly IRepository<StockAdjustment> _adjustments;
|
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||||
private readonly IFifoCostingService _fifo;
|
private readonly IFifoCostingService _fifo;
|
||||||
private readonly IStockMutator _mutator;
|
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IBranchConfigService _branchConfig;
|
private readonly IBranchConfigService _branchConfig;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
|
||||||
private readonly IRepository<StockLayer> _stockLayers;
|
private readonly IRepository<StockLayer> _stockLayers;
|
||||||
private readonly IRepository<Batch> _batches;
|
private readonly IRepository<Batch> _batches;
|
||||||
private readonly IRepository<Serial> _serials;
|
private readonly IRepository<Serial> _serials;
|
||||||
private readonly IGlAccountConfigService _glAccounts;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
|
|
||||||
public CountService(
|
public CountService(
|
||||||
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
IRepository<StockCount> counts, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
|
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
|
||||||
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
|
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
|
||||||
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
|
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
|
||||||
{
|
{
|
||||||
_serialTracking = serialTracking;
|
_serialTracking = serialTracking;
|
||||||
_counts = counts;
|
_counts = counts;
|
||||||
_adjustments = adjustments;
|
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_items = items;
|
_items = items;
|
||||||
_reasonCodes = reasonCodes;
|
_reasonCodes = reasonCodes;
|
||||||
_fifo = fifo;
|
_fifo = fifo;
|
||||||
_mutator = mutator;
|
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_branchConfig = branchConfig;
|
_branchConfig = branchConfig;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
|
||||||
_stockLayers = stockLayers;
|
_stockLayers = stockLayers;
|
||||||
_batches = batches;
|
_batches = batches;
|
||||||
_serials = serials;
|
_serials = serials;
|
||||||
@@ -266,13 +260,9 @@ public sealed class CountService : ICountService
|
|||||||
|
|
||||||
// Batch and serial lines adjust exactly their own lot/unit.
|
// Batch and serial lines adjust exactly their own lot/unit.
|
||||||
var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList();
|
var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList();
|
||||||
var deltas = varianceLines
|
|
||||||
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.Variance!.Value,
|
|
||||||
l.SerialId is int serialId ? new List<int> { serialId } : null))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// No variances → just close the count, no adjustment.
|
// No variances → just close the count, no adjustment.
|
||||||
if (deltas.Count == 0)
|
if (varianceLines.Count == 0)
|
||||||
{
|
{
|
||||||
count.Status = CountStatus.Posted;
|
count.Status = CountStatus.Posted;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
@@ -283,53 +273,33 @@ public sealed class CountService : ICountService
|
|||||||
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
|
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
|
||||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
// A serial counted missing is written off (Scrapped); one found is brought back InStock —
|
||||||
var (adjustmentId, glJournalNo, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
// the adjustment's own serial rules, applied when it posts.
|
||||||
|
var request = new CreateAdjustmentRequest
|
||||||
{
|
{
|
||||||
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
|
||||||
var adjustment = new StockAdjustment
|
|
||||||
{
|
|
||||||
DocNo = docNo,
|
|
||||||
WarehouseId = count.WarehouseId,
|
WarehouseId = count.WarehouseId,
|
||||||
BranchId = count.BranchId,
|
BranchId = count.BranchId,
|
||||||
ReasonCodeId = reason.ReasonCodeId,
|
ReasonCodeId = reason.ReasonCodeId,
|
||||||
Status = AdjustmentStatus.Posted,
|
Lines = varianceLines.Select(l => new CreateAdjustmentLineInput
|
||||||
CreatedBy = _currentUser.AuditUserId,
|
|
||||||
CreatedAt = now,
|
|
||||||
Lines = deltas.Select(d => new StockAdjustmentLine
|
|
||||||
{
|
{
|
||||||
ItemId = d.ItemId,
|
ItemId = l.ItemId,
|
||||||
BinId = d.BinId,
|
BinId = l.BinId,
|
||||||
BatchId = d.BatchId,
|
BatchId = l.BatchId,
|
||||||
SerialId = d.SerialIds?.FirstOrDefault(),
|
QtyDelta = l.Variance!.Value,
|
||||||
QtyDelta = d.QtyDelta
|
SerialIds = l.SerialId is int missing && l.Variance < 0 ? [missing] : null,
|
||||||
|
SerialNos = l.Serial is not null && l.Variance > 0 ? [l.Serial.SerialNo] : null
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
await _adjustments.AddAsync(adjustment, token);
|
|
||||||
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
|
|
||||||
|
|
||||||
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
|
var (adjustment, refs) = await _adjustments.SubmitAsync(request, _ =>
|
||||||
|
|
||||||
// A serial counted missing is written off (Scrapped); one found keeps/regains InStock here.
|
|
||||||
foreach (var line in varianceLines.Where(l => l.SerialId is not null))
|
|
||||||
{
|
{
|
||||||
var serial = await _serials.Query().FirstAsync(s => s.SerialId == line.SerialId, token);
|
|
||||||
var found = line.Variance > 0;
|
|
||||||
await _serialTracking.MoveAsync(new[] { serial },
|
|
||||||
found ? SerialStatus.InStock : SerialStatus.Scrapped,
|
|
||||||
found ? count.WarehouseId : null, found ? line.BinId : null,
|
|
||||||
DocumentTypes.Count, count.CountId, now, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
|
||||||
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
|
|
||||||
|
|
||||||
count.Status = CountStatus.Posted;
|
count.Status = CountStatus.Posted;
|
||||||
return (adjustment.AdjustmentId, adjustment.GlJournalNo, refs);
|
return Task.CompletedTask;
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
// Map ledger ids after commit so they are populated.
|
return new CountPostResultDto(
|
||||||
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, glJournalNo, ledgerEntries.Select(r => r.LedgerId).ToList());
|
count.CountId, count.Status, adjustment.AdjustmentId, adjustment.GlJournalNo, refs.Select(r => r.LedgerId).ToList(),
|
||||||
|
adjustment.Status, adjustment.DocNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CountDto Map(StockCount c) => new(
|
private static CountDto Map(StockCount c) => new(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class CustomerService : ICustomerService
|
|||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IBranchConfigService _branchConfig;
|
private readonly IBranchConfigService _branchConfig;
|
||||||
private readonly IRepository<SalesConfig> _salesConfig;
|
private readonly IRepository<SalesConfig> _salesConfig;
|
||||||
|
private readonly IRepository<SalesInvoice> _invoices;
|
||||||
|
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
|
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
|
||||||
@@ -24,12 +26,15 @@ public sealed class CustomerService : ICustomerService
|
|||||||
|
|
||||||
public CustomerService(
|
public CustomerService(
|
||||||
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
|
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
|
||||||
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
|
IRepository<SalesConfig> salesConfig, IRepository<SalesInvoice> invoices,
|
||||||
|
IRepository<SalesInvoicePayment> invoicePayments, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_branchConfig = branchConfig;
|
_branchConfig = branchConfig;
|
||||||
_salesConfig = salesConfig;
|
_salesConfig = salesConfig;
|
||||||
|
_invoices = invoices;
|
||||||
|
_invoicePayments = invoicePayments;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +75,40 @@ public sealed class CustomerService : ICustomerService
|
|||||||
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt))
|
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<CustomerDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<CustomerDto>.Create(await WithReceivablesAsync(rows, ct), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds each customer's receivable position — the unpaid balance and count of their Posted sales
|
||||||
|
/// invoices, and their latest invoice payment — for just the customers on this page.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<CustomerDto>> WithReceivablesAsync(List<CustomerDto> rows, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return rows;
|
||||||
|
var ids = rows.Select(r => r.CustomerId).ToList();
|
||||||
|
|
||||||
|
var dues = await _invoices.Query().AsNoTracking()
|
||||||
|
.Where(i => ids.Contains(i.CustomerId) && i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0)
|
||||||
|
.GroupBy(i => i.CustomerId)
|
||||||
|
.Select(g => new { CustomerId = g.Key, Amount = g.Sum(i => i.BalanceAmount), Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.CustomerId, ct);
|
||||||
|
|
||||||
|
var lastPaid = await _invoicePayments.Query().AsNoTracking()
|
||||||
|
.Where(p => ids.Contains(p.SalesInvoice!.CustomerId))
|
||||||
|
.GroupBy(p => p.SalesInvoice!.CustomerId)
|
||||||
|
.Select(g => new { CustomerId = g.Key, Last = g.Max(p => p.PaymentDate) })
|
||||||
|
.ToDictionaryAsync(x => x.CustomerId, x => x.Last, ct);
|
||||||
|
|
||||||
|
return rows.Select(r =>
|
||||||
|
{
|
||||||
|
var due = dues.GetValueOrDefault(r.CustomerId);
|
||||||
|
return r with
|
||||||
|
{
|
||||||
|
OutstandingAmount = due?.Amount ?? 0m,
|
||||||
|
OutstandingInvoiceCount = due?.Count ?? 0,
|
||||||
|
LastPaymentDate = lastPaid.TryGetValue(r.CustomerId, out var last) ? last : null
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
|
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Accounts;
|
||||||
|
using ERPCore.Dtos.Reports;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Sales documents are stamped in
|
||||||
|
/// UTC, so each business day is the <see cref="BusinessDayClock"/> calendar day converted to a UTC
|
||||||
|
/// <c>[start, end)</c> window. The database does the filtering and returns slim projections; the
|
||||||
|
/// rows are bucketed into local days here, since MariaDB can't group by a time-zone-shifted date
|
||||||
|
/// portably and a ≤ 92-day window of slim rows is small. Cost of goods sold is the stock ledger's
|
||||||
|
/// Out value per sale document — the same source <see cref="SalesDayEndService"/> posts COGS from.
|
||||||
|
/// The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's
|
||||||
|
/// <see cref="ICashInHandService"/> (docs/BANK_DEPOSIT_MODULE_PLAN.md §9); this report owns none of them.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DayEndReportService : IDayEndReportService
|
||||||
|
{
|
||||||
|
private const int MaxRangeDays = 92;
|
||||||
|
private const int IdChunk = 1000;
|
||||||
|
|
||||||
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
private readonly IRepository<BundleSale> _bundles;
|
||||||
|
private readonly IRepository<SalesInvoice> _invoices;
|
||||||
|
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
|
||||||
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
|
private readonly IRepository<Expense> _expenses;
|
||||||
|
private readonly IRepository<BankDeposit> _deposits;
|
||||||
|
private readonly ICashInHandService _cash;
|
||||||
|
private readonly ICurrentBranchContext _currentBranch;
|
||||||
|
private readonly BusinessDayClock _clock;
|
||||||
|
|
||||||
|
public DayEndReportService(
|
||||||
|
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoice> invoices,
|
||||||
|
IRepository<SalesInvoicePayment> invoicePayments, IRepository<StockLedger> ledger,
|
||||||
|
IRepository<Expense> expenses, IRepository<BankDeposit> deposits, ICashInHandService cash,
|
||||||
|
ICurrentBranchContext currentBranch, BusinessDayClock clock)
|
||||||
|
{
|
||||||
|
_slips = slips;
|
||||||
|
_bundles = bundles;
|
||||||
|
_invoices = invoices;
|
||||||
|
_invoicePayments = invoicePayments;
|
||||||
|
_ledger = ledger;
|
||||||
|
_expenses = expenses;
|
||||||
|
_deposits = deposits;
|
||||||
|
_cash = cash;
|
||||||
|
_currentBranch = currentBranch;
|
||||||
|
_clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DayEndReportDto> GetDayAsync(DateOnly? requestedDate, int? branchId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var date = requestedDate ?? _clock.Today;
|
||||||
|
var branch = EffectiveBranch(branchId);
|
||||||
|
var data = await LoadAsync(date, date, branch, ct);
|
||||||
|
|
||||||
|
var sales = data.Sales
|
||||||
|
.OrderBy(s => s.At)
|
||||||
|
.Select(s => new DayEndReportSaleDto(
|
||||||
|
s.DocType, s.DocId, s.DocNo, s.At, s.Customer, s.Total, s.Paid, s.Total - s.Paid, s.PaidBy, s.Cost, s.Total - s.Cost))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var expenses = await PostedExpenses(date, date, branch)
|
||||||
|
.OrderBy(e => e.ExpenseId)
|
||||||
|
.Select(e => new DayEndReportExpenseDto(
|
||||||
|
e.ExpenseId, e.DocNo, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var depositsQuery = _deposits.Query().AsNoTracking()
|
||||||
|
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate == date);
|
||||||
|
if (branch is not null) depositsQuery = depositsQuery.Where(d => d.BranchId == branch);
|
||||||
|
var deposits = await depositsQuery
|
||||||
|
.OrderBy(d => d.BankDepositId)
|
||||||
|
.Select(d => new DayEndReportDepositDto(
|
||||||
|
d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new DayEndReportDto(data.Days[0], sales, expenses, deposits, branch, _clock.Zone.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DayEndDailyReportDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (to < from)
|
||||||
|
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, "'to' must be on or after 'from'.", 400);
|
||||||
|
if (to.DayNumber - from.DayNumber + 1 > MaxRangeDays)
|
||||||
|
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, $"The range can be at most {MaxRangeDays} days.", 400);
|
||||||
|
|
||||||
|
var branch = EffectiveBranch(branchId);
|
||||||
|
var data = await LoadAsync(from, to, branch, ct);
|
||||||
|
return new DayEndDailyReportDto(from, to, data.Days, Sum(data.Days), branch, _clock.Zone.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A branch-scoped (non-admin) caller always sees only their own branch, whatever they ask for.</summary>
|
||||||
|
private int? EffectiveBranch(int? requested) => _currentBranch.IsActive ? _currentBranch.BranchId : requested;
|
||||||
|
|
||||||
|
/// <summary>One sale, whatever document it is. Cash/Card/Other are how its Paid amount was paid.</summary>
|
||||||
|
private sealed record Sale(
|
||||||
|
string DocType, int DocId, string DocNo, DateTime At, DateOnly Day, string Customer,
|
||||||
|
decimal Total, decimal Paid, decimal Cash, decimal Card, decimal Other, string PaidBy, decimal Cost);
|
||||||
|
|
||||||
|
private sealed record Loaded(List<DayEndReportDayDto> Days, List<Sale> Sales);
|
||||||
|
|
||||||
|
private async Task<Loaded> LoadAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var start = _clock.ToUtc(from);
|
||||||
|
var end = _clock.ToUtc(to.AddDays(1));
|
||||||
|
|
||||||
|
var slipsQuery = _slips.Query().AsNoTracking()
|
||||||
|
.Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end);
|
||||||
|
if (branch is not null) slipsQuery = slipsQuery.Where(s => s.Warehouse!.BranchId == branch);
|
||||||
|
var slips = await slipsQuery
|
||||||
|
.Select(s => new { s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var bundlesQuery = _bundles.Query().AsNoTracking()
|
||||||
|
.Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end);
|
||||||
|
if (branch is not null) bundlesQuery = bundlesQuery.Where(b => b.Warehouse!.BranchId == branch);
|
||||||
|
var bundles = await bundlesQuery
|
||||||
|
.Select(b => new { b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var invoicesQuery = _invoices.Query().AsNoTracking()
|
||||||
|
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= start && i.InvoiceDate < end);
|
||||||
|
if (branch is not null) invoicesQuery = invoicesQuery.Where(i => i.BranchId == branch);
|
||||||
|
var invoices = await invoicesQuery
|
||||||
|
.Select(i => new { i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, i.CustomerSnapshotName, i.NetPayable, i.PaidAmount })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Every payment that matters to the window: those against its invoices (the method split of
|
||||||
|
// Paid), and those received inside it against earlier invoices (collections / net cash).
|
||||||
|
var paymentsQuery = _invoicePayments.Query().AsNoTracking()
|
||||||
|
.Where(p => p.SalesInvoice!.Status == SalesInvoiceStatus.Posted
|
||||||
|
&& ((p.SalesInvoice.InvoiceDate >= start && p.SalesInvoice.InvoiceDate < end)
|
||||||
|
|| (p.PaymentDate >= start && p.PaymentDate < end)));
|
||||||
|
if (branch is not null) paymentsQuery = paymentsQuery.Where(p => p.SalesInvoice!.BranchId == branch);
|
||||||
|
var payments = await paymentsQuery
|
||||||
|
.Select(p => new { p.SalesInvoiceId, InvoiceDate = p.SalesInvoice!.InvoiceDate, p.PaymentDate, p.PaymentMethod, p.Amount })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var slipCost = await CostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct);
|
||||||
|
var bundleCost = await CostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct);
|
||||||
|
var invoiceCost = await CostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct);
|
||||||
|
|
||||||
|
var expenses = await PostedExpenses(from, to, branch)
|
||||||
|
.Select(e => new { e.ExpenseDate, e.Amount })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Branch is already the effective one, so the cash service scopes it the same way.
|
||||||
|
var cashByDay = (await _cash.GetRangeAsync(from, to, branch, ct)).ToDictionary(c => c.Date);
|
||||||
|
|
||||||
|
var paymentsByInvoice = payments.GroupBy(p => p.SalesInvoiceId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var sales = new List<Sale>(slips.Count + bundles.Count + invoices.Count);
|
||||||
|
sales.AddRange(slips.Select(s => new Sale(
|
||||||
|
"Slip", s.SalesSlipId, s.SlipNo, s.SlipDate, LocalDay(s.SlipDate), s.CustomerSnapshotName,
|
||||||
|
s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount, 0m, PaidBy(s.CashAmount, s.CardAmount, 0m, null),
|
||||||
|
slipCost.GetValueOrDefault(s.SalesSlipId))));
|
||||||
|
sales.AddRange(bundles.Select(b => new Sale(
|
||||||
|
"Bundle", b.BundleSaleId, b.BundleNo, b.BundleDate, LocalDay(b.BundleDate), b.CustomerSnapshotName,
|
||||||
|
b.GrandTotal, b.GrandTotal, b.CashAmount, b.CardAmount, 0m, PaidBy(b.CashAmount, b.CardAmount, 0m, null),
|
||||||
|
bundleCost.GetValueOrDefault(b.BundleSaleId))));
|
||||||
|
foreach (var i in invoices)
|
||||||
|
{
|
||||||
|
var own = paymentsByInvoice.GetValueOrDefault(i.SalesInvoiceId) ?? [];
|
||||||
|
var cash = own.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash).Sum(p => p.Amount);
|
||||||
|
var card = own.Where(p => p.PaymentMethod == SalesPaymentMethod.Card).Sum(p => p.Amount);
|
||||||
|
var others = own.Where(p => p.PaymentMethod is not (SalesPaymentMethod.Cash or SalesPaymentMethod.Card)).ToList();
|
||||||
|
var other = others.Sum(p => p.Amount);
|
||||||
|
var otherMethods = others.Select(p => p.PaymentMethod).Distinct().ToList();
|
||||||
|
sales.Add(new Sale(
|
||||||
|
"Invoice", i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, LocalDay(i.InvoiceDate), i.CustomerSnapshotName,
|
||||||
|
i.NetPayable, i.PaidAmount, cash, card, other,
|
||||||
|
PaidBy(cash, card, other, otherMethods.Count == 1 ? otherMethods[0] : null),
|
||||||
|
invoiceCost.GetValueOrDefault(i.SalesInvoiceId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var days = new List<DayEndReportDayDto>(to.DayNumber - from.DayNumber + 1);
|
||||||
|
for (var day = from; day <= to; day = day.AddDays(1))
|
||||||
|
{
|
||||||
|
var d = day;
|
||||||
|
var acc = new DayAccumulator();
|
||||||
|
foreach (var s in sales.Where(s => s.Day == d)) acc.AddSale(s);
|
||||||
|
|
||||||
|
var receivedToday = payments.Where(p => LocalDay(p.PaymentDate) == d).ToList();
|
||||||
|
acc.EarlierCollections = receivedToday.Where(p => LocalDay(p.InvoiceDate) < d).Sum(p => p.Amount);
|
||||||
|
acc.ExpenseTotal = expenses.Where(e => e.ExpenseDate == d).Sum(e => e.Amount);
|
||||||
|
acc.Cash = cashByDay[d];
|
||||||
|
|
||||||
|
days.Add(acc.ToDto(d));
|
||||||
|
}
|
||||||
|
return new Loaded(days, sales);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sum of the stock ledger's Out value per document — the FIFO cost the sale consumed.</summary>
|
||||||
|
private async Task<Dictionary<int, decimal>> CostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<int, decimal>();
|
||||||
|
foreach (var chunk in docIds.Distinct().Chunk(IdChunk))
|
||||||
|
{
|
||||||
|
var rows = await _ledger.Query().AsNoTracking()
|
||||||
|
.Where(l => l.SourceDocType == docType && l.Direction == Direction.Out && chunk.Contains(l.SourceDocId))
|
||||||
|
.GroupBy(l => l.SourceDocId)
|
||||||
|
.Select(g => new { DocId = g.Key, Cost = g.Sum(l => l.Value) })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var r in rows) result[r.DocId] = r.Cost;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PaidBy(decimal cash, decimal card, decimal other, SalesPaymentMethod? singleOther)
|
||||||
|
{
|
||||||
|
var ways = (cash > 0 ? 1 : 0) + (card > 0 ? 1 : 0) + (other > 0 ? 1 : 0);
|
||||||
|
if (ways == 0) return "Unpaid";
|
||||||
|
if (ways > 1 || (other > 0 && singleOther is null)) return "Mixed";
|
||||||
|
if (cash > 0) return "Cash";
|
||||||
|
if (card > 0) return "Card";
|
||||||
|
return singleOther == SalesPaymentMethod.Cheque ? "Cheque" : "Bank transfer";
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Expense> PostedExpenses(DateOnly from, DateOnly to, int? branch)
|
||||||
|
{
|
||||||
|
var q = _expenses.Query().AsNoTracking()
|
||||||
|
.Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= from && e.ExpenseDate <= to);
|
||||||
|
return branch is null ? q : q.Where(e => e.BranchId == branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateOnly LocalDay(DateTime utc) => _clock.LocalDay(utc);
|
||||||
|
|
||||||
|
/// <summary>Flows are summed; cash in hand is a balance, so the range opens with its first day and closes with its last.</summary>
|
||||||
|
private static DayEndReportDayDto Sum(IReadOnlyList<DayEndReportDayDto> days) => new(
|
||||||
|
null,
|
||||||
|
days.Sum(d => d.TotalSales), days.Sum(d => d.PaidAmount), days.Sum(d => d.DueAmount),
|
||||||
|
days.Sum(d => d.PaidByCash), days.Sum(d => d.PaidByCard), days.Sum(d => d.PaidByOther),
|
||||||
|
days.Sum(d => d.SalesCount), days.Sum(d => d.CashSalesCount), days.Sum(d => d.CardSalesCount),
|
||||||
|
days.Sum(d => d.MixedSalesCount), days.Sum(d => d.OtherSalesCount), days.Sum(d => d.UnpaidSalesCount),
|
||||||
|
days.Sum(d => d.ExpenseTotal), days.Sum(d => d.Income),
|
||||||
|
days.Sum(d => d.CostOfGoodsSold), days.Sum(d => d.GrossProfit), days.Sum(d => d.NetProfit),
|
||||||
|
days.Sum(d => d.EarlierInvoiceCollections), days.Sum(d => d.NetCash),
|
||||||
|
days[0].OpeningCashInHand, days.Sum(d => d.CashDeposited), days[^1].ClosingCashInHand);
|
||||||
|
|
||||||
|
private sealed class DayAccumulator
|
||||||
|
{
|
||||||
|
private decimal _total, _paid, _cash, _card, _other, _cost;
|
||||||
|
private int _count, _cashCount, _cardCount, _mixedCount, _otherCount, _unpaidCount;
|
||||||
|
public decimal ExpenseTotal { get; set; }
|
||||||
|
public decimal EarlierCollections { get; set; }
|
||||||
|
public CashPositionDto? Cash { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A sale counts as a cash/card/other sale when that is the only way it was paid,
|
||||||
|
/// mixed when paid more than one way, unpaid when nothing has been paid yet.</summary>
|
||||||
|
public void AddSale(Sale s)
|
||||||
|
{
|
||||||
|
_total += s.Total;
|
||||||
|
_paid += s.Paid;
|
||||||
|
_cash += s.Cash;
|
||||||
|
_card += s.Card;
|
||||||
|
_other += s.Other;
|
||||||
|
_cost += s.Cost;
|
||||||
|
_count++;
|
||||||
|
|
||||||
|
var ways = (s.Cash > 0 ? 1 : 0) + (s.Card > 0 ? 1 : 0) + (s.Other > 0 ? 1 : 0);
|
||||||
|
if (ways == 0) _unpaidCount++;
|
||||||
|
else if (ways > 1) _mixedCount++;
|
||||||
|
else if (s.Cash > 0) _cashCount++;
|
||||||
|
else if (s.Card > 0) _cardCount++;
|
||||||
|
else _otherCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DayEndReportDayDto ToDto(DateOnly date) => new(
|
||||||
|
date, _total, _paid, _total - _paid, _cash, _card, _other,
|
||||||
|
_count, _cashCount, _cardCount, _mixedCount, _otherCount, _unpaidCount,
|
||||||
|
ExpenseTotal, _total - ExpenseTotal,
|
||||||
|
_cost, _total - _cost, _total - _cost - ExpenseTotal,
|
||||||
|
EarlierCollections,
|
||||||
|
Cash?.NetCash ?? 0m, Cash?.Opening ?? 0m, Cash?.Deposited ?? 0m, Cash?.Closing ?? 0m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Expenses;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payments against a Posted expense (docs/EXPENSE_MODULE_PLAN.md §4). One submission may
|
||||||
|
/// split across several cash/bank accounts; it posts a single GL journal entry (Debit Accrued
|
||||||
|
/// Expenses / Credit each account) and records one <see cref="ExpensePayment"/> row per line,
|
||||||
|
/// all sharing that journal number. Same call-GL-before-commit pattern as
|
||||||
|
/// <see cref="PurchaseInvoicePaymentService"/>, so a rejected/unreachable GL post rolls the
|
||||||
|
/// whole payment back. Post & Pay runs the accrual and a full or partial payment in one transaction.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExpensePaymentService : IExpensePaymentService
|
||||||
|
{
|
||||||
|
private readonly IRepository<Expense> _expenses;
|
||||||
|
private readonly IRepository<ExpensePayment> _payments;
|
||||||
|
private readonly ExpenseService _expenseService;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly string _glAccruedExpensesCode;
|
||||||
|
|
||||||
|
public ExpensePaymentService(
|
||||||
|
IRepository<Expense> expenses, IRepository<ExpensePayment> payments, ExpenseService expenseService,
|
||||||
|
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_expenses = expenses;
|
||||||
|
_payments = payments;
|
||||||
|
_expenseService = expenseService;
|
||||||
|
_gl = gl;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_uow = uow;
|
||||||
|
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExpensePaymentDto>> PayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var expense = await _expenseService.LoadAsync(expenseId, tracking: true, ct)
|
||||||
|
?? throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||||
|
|
||||||
|
if (expense.Status != ExpenseStatus.Posted || expense.BalanceAmount <= 0)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseNotPayable,
|
||||||
|
$"Expense {expense.DocNo} must be posted with a balance outstanding before it can be paid.", 409);
|
||||||
|
|
||||||
|
var total = ValidateRequest(request);
|
||||||
|
if (total > expense.BalanceAmount)
|
||||||
|
throw new DomainException(ErrorCodes.ExpensePaymentExceedsBalance,
|
||||||
|
$"Payment total {total} exceeds the remaining balance {expense.BalanceAmount}.", 400);
|
||||||
|
|
||||||
|
var accounts = await ResolveAccountsAsync(request, ct);
|
||||||
|
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, DateTime.UtcNow);
|
||||||
|
|
||||||
|
var recorded = await _uow.ExecuteInTransactionAsync(
|
||||||
|
token => RecordPaymentAsync(expense, request, accounts, paymentDay, paymentDate, token), ct);
|
||||||
|
|
||||||
|
return recorded.Select(ExpenseService.MapPayment).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseDto> PostAndPayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var expense = await _expenseService.LoadAsync(expenseId, tracking: true, ct)
|
||||||
|
?? throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||||
|
|
||||||
|
if (expense.Status != ExpenseStatus.Draft)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status}; use the payments endpoint instead.", 409);
|
||||||
|
|
||||||
|
// Full or partial: whatever is left stays as the balance, payable later like any Posted expense.
|
||||||
|
var total = ValidateRequest(request);
|
||||||
|
if (total > expense.Amount)
|
||||||
|
throw new DomainException(ErrorCodes.ExpensePaymentExceedsBalance,
|
||||||
|
$"Payment total {total} exceeds the expense amount {expense.Amount}.", 400);
|
||||||
|
|
||||||
|
var accounts = await ResolveAccountsAsync(request, ct);
|
||||||
|
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, DateTime.UtcNow);
|
||||||
|
|
||||||
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
await _expenseService.PostInCurrentTransactionAsync(expense, token);
|
||||||
|
return await RecordPaymentAsync(expense, request, accounts, paymentDay, paymentDate, token);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return ExpenseService.Map(expense);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await _expenses.Query().AnyAsync(x => x.ExpenseId == expenseId, ct))
|
||||||
|
throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||||
|
|
||||||
|
var payments = await _payments.Query().AsNoTracking()
|
||||||
|
.Where(p => p.ExpenseId == expenseId)
|
||||||
|
.OrderByDescending(p => p.ExpensePaymentId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return payments.Select(ExpenseService.MapPayment).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Posts the settlement journal and records the payment rows. Runs inside the caller's transaction.</summary>
|
||||||
|
private async Task<List<ExpensePayment>> RecordPaymentAsync(
|
||||||
|
Expense expense, CreateExpensePaymentRequest request, IReadOnlyDictionary<long, GlBankAccount> accounts,
|
||||||
|
DateOnly paymentDay, DateTime paymentDate, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var total = request.Lines.Sum(l => l.Amount);
|
||||||
|
var narration = $"Payment of expense {expense.DocNo}";
|
||||||
|
var glLines = new List<GlJournalEntryLineRequest> { new(_glAccruedExpensesCode, total, 0m, narration) };
|
||||||
|
glLines.AddRange(request.Lines.Select(l => new GlJournalEntryLineRequest(accounts[l.GlBankAccountId].GlAccountCode, 0m, l.Amount, narration)));
|
||||||
|
|
||||||
|
var period = await _gl.GetPeriodByDateAsync(paymentDay, ct);
|
||||||
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
PeriodId = period.PeriodId,
|
||||||
|
EntryDate = paymentDay,
|
||||||
|
SourceModule = "EXPENSE_PAYMENT",
|
||||||
|
Reference = expense.DocNo,
|
||||||
|
Narration = narration,
|
||||||
|
Lines = glLines
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
var actor = _currentUser.AuditUserId;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var chequeNo = request.PaymentMethod == ExpensePaymentMethod.Cheque ? request.ChequeNo?.Trim() : null;
|
||||||
|
var reference = string.IsNullOrWhiteSpace(request.Reference) ? null : request.Reference.Trim();
|
||||||
|
|
||||||
|
var rows = request.Lines.Select(l => new ExpensePayment
|
||||||
|
{
|
||||||
|
ExpenseId = expense.ExpenseId,
|
||||||
|
Amount = l.Amount,
|
||||||
|
PaymentDate = paymentDate,
|
||||||
|
PaymentMethod = request.PaymentMethod,
|
||||||
|
GlBankAccountId = l.GlBankAccountId,
|
||||||
|
BankAccountName = accounts[l.GlBankAccountId].AccountName,
|
||||||
|
ChequeNo = chequeNo,
|
||||||
|
Reference = reference,
|
||||||
|
GlJournalNo = posted.JournalNo,
|
||||||
|
CreatedBy = actor,
|
||||||
|
CreatedAt = now
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
foreach (var row in rows) expense.Payments.Add(row);
|
||||||
|
expense.PaidAmount += total;
|
||||||
|
expense.BalanceAmount -= total;
|
||||||
|
expense.PaymentStatus = ExpenseService.DerivePaymentStatus(expense.PaidAmount, expense.BalanceAmount);
|
||||||
|
expense.UpdatedAt = now;
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ValidateRequest(CreateExpensePaymentRequest request)
|
||||||
|
{
|
||||||
|
if (request.Lines.Count == 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Add at least one payment line.", 400);
|
||||||
|
if (request.Lines.Any(l => l.Amount <= 0))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Every payment line needs an amount greater than zero.", 400);
|
||||||
|
if (request.PaymentMethod == ExpensePaymentMethod.Cheque && string.IsNullOrWhiteSpace(request.ChequeNo))
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseChequeNoRequired, "Enter the cheque number for a cheque payment.", 400);
|
||||||
|
return request.Lines.Sum(l => l.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyDictionary<long, GlBankAccount>> ResolveAccountsAsync(CreateExpensePaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var all = await _gl.ListBankAccountsAsync(ct);
|
||||||
|
var result = new Dictionary<long, GlBankAccount>();
|
||||||
|
foreach (var id in request.Lines.Select(l => l.GlBankAccountId).Distinct())
|
||||||
|
{
|
||||||
|
result[id] = all.FirstOrDefault(a => a.AccountId == id)
|
||||||
|
?? throw new DomainException(ErrorCodes.ExpenseBankAccountNotFound, $"Bank/cash account {id} was not found.", 404);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Same rule as <see cref="PurchaseInvoicePaymentService"/>: omitted ⇒ today, one day of
|
||||||
|
/// slack on "future" for clients east of UTC; a back-dated payment is stamped at that day's start.</summary>
|
||||||
|
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(now);
|
||||||
|
var day = requested ?? today;
|
||||||
|
if (day > today.AddDays(1))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
||||||
|
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Expenses;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Business expense bills (docs/EXPENSE_MODULE_PLAN.md §4). Draft is editable and has no GL
|
||||||
|
/// impact; Post accrues it (Debit the title's expense account / Credit Accrued Expenses) via a
|
||||||
|
/// real GL journal entry posted before commit — same atomicity approach as
|
||||||
|
/// <see cref="PurchaseInvoiceService.PostAsync"/>, so a GL rejection rolls the post back.
|
||||||
|
/// Payments settle the accrual (<see cref="ExpensePaymentService"/>). Cancelling a Posted
|
||||||
|
/// expense with no payments posts the reversing entry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExpenseService : IExpenseService
|
||||||
|
{
|
||||||
|
private readonly IRepository<Expense> _expenses;
|
||||||
|
private readonly IRepository<ExpenseTitle> _titles;
|
||||||
|
private readonly IRepository<Vendor> _vendors;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly INumberSequenceService _numbers;
|
||||||
|
private readonly IBranchConfigService _branchConfig;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly string _glAccruedExpensesCode;
|
||||||
|
|
||||||
|
public ExpenseService(
|
||||||
|
IRepository<Expense> expenses, IRepository<ExpenseTitle> titles, IRepository<Vendor> vendors,
|
||||||
|
ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
|
||||||
|
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_expenses = expenses;
|
||||||
|
_titles = titles;
|
||||||
|
_vendors = vendors;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_numbers = numbers;
|
||||||
|
_branchConfig = branchConfig;
|
||||||
|
_uow = uow;
|
||||||
|
_gl = gl;
|
||||||
|
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<Expense> q = _expenses.Query().AsNoTracking().Include(x => x.Category).Include(x => x.Title);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|
||||||
|
|| (x.Payee != null && EF.Functions.Like(x.Payee, $"%{term}%"))
|
||||||
|
|| (x.BillReference != null && EF.Functions.Like(x.BillReference, $"%{term}%")));
|
||||||
|
}
|
||||||
|
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
|
||||||
|
if (filter.PaymentStatus is not null) q = q.Where(x => x.Status == ExpenseStatus.Posted && x.PaymentStatus == filter.PaymentStatus);
|
||||||
|
if (filter.CategoryId is not null) q = q.Where(x => x.ExpenseCategoryId == filter.CategoryId);
|
||||||
|
if (filter.TitleId is not null) q = q.Where(x => x.ExpenseTitleId == filter.TitleId);
|
||||||
|
if (filter.From is not null) q = q.Where(x => x.ExpenseDate >= filter.From);
|
||||||
|
if (filter.To is not null) q = q.Where(x => x.ExpenseDate <= filter.To);
|
||||||
|
if (filter.OverdueOnly)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today);
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(x => x.ExpenseDate).ThenByDescending(x => x.ExpenseId)
|
||||||
|
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
|
||||||
|
return PagedResponse<ExpenseSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ExpenseDto>?> GetAsync(int expenseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var expense = await LoadAsync(expenseId, tracking: false, ct);
|
||||||
|
return expense is null ? null : new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ExpenseDto>> CreateAsync(CreateExpenseRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var title = await ValidateTitleAsync(request, ct);
|
||||||
|
var vendorName = await ValidateVendorAsync(request.VendorId, ct);
|
||||||
|
ValidateDates(request);
|
||||||
|
|
||||||
|
var branchId = request.BranchId > 0
|
||||||
|
? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct)
|
||||||
|
: await _branchConfig.ResolveDefaultBranchIdAsync(ct);
|
||||||
|
|
||||||
|
var expense = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
var entity = new Expense
|
||||||
|
{
|
||||||
|
DocNo = await _numbers.NextAsync(DocumentTypes.Expense, branchId, token),
|
||||||
|
BranchId = branchId,
|
||||||
|
Status = ExpenseStatus.Draft,
|
||||||
|
PaymentStatus = ExpensePaymentStatus.Unpaid,
|
||||||
|
CreatedBy = _currentUser.AuditUserId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
Apply(entity, request, vendorName);
|
||||||
|
await _expenses.AddAsync(entity, token);
|
||||||
|
return entity;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
expense.Category = title.Category;
|
||||||
|
expense.Title = title;
|
||||||
|
return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ExpenseDto>> UpdateAsync(int expenseId, UpdateExpenseRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var expense = await LoadAsync(expenseId, tracking: true, ct)
|
||||||
|
?? throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||||
|
|
||||||
|
if (expense.RowVersion != expectedRowVersion)
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
|
||||||
|
EnsureDraft(expense, "edited");
|
||||||
|
|
||||||
|
var title = await ValidateTitleAsync(request, ct);
|
||||||
|
var vendorName = await ValidateVendorAsync(request.VendorId, ct);
|
||||||
|
ValidateDates(request);
|
||||||
|
|
||||||
|
if (request.BranchId > 0 && request.BranchId != expense.BranchId)
|
||||||
|
expense.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||||
|
|
||||||
|
Apply(expense, request, vendorName);
|
||||||
|
expense.Category = title.Category;
|
||||||
|
expense.Title = title;
|
||||||
|
expense.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await SaveAsync(ct);
|
||||||
|
return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int expenseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var expense = await _expenses.Query().FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct)
|
||||||
|
?? throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||||
|
EnsureDraft(expense, "deleted");
|
||||||
|
|
||||||
|
_expenses.Remove(expense);
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var expense = await LoadAsync(expenseId, tracking: true, ct)
|
||||||
|
?? throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||||
|
|
||||||
|
await _uow.ExecuteInTransactionAsync(token => PostInCurrentTransactionAsync(expense, token), ct);
|
||||||
|
return Map(expense);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Posts the accrual entry and flips the expense to Posted. Must run inside the caller's
|
||||||
|
/// transaction (the caller commits) — shared with <see cref="ExpensePaymentService.PostAndPayAsync"/>
|
||||||
|
/// so Post & Pay is one atomic unit.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task<int> PostInCurrentTransactionAsync(Expense expense, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureDraft(expense, "posted");
|
||||||
|
var title = expense.Title ?? throw new InvalidOperationException("Expense title must be loaded before posting.");
|
||||||
|
if (title.Status != EntityStatus.Active)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseTitleInactive, $"Expense title '{title.Name}' is inactive — pick another title before posting.", 409);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var narration = $"{expense.Category?.Name} - {title.Name} ({expense.DocNo})";
|
||||||
|
var period = await _gl.GetPeriodByDateAsync(expense.ExpenseDate, ct);
|
||||||
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
PeriodId = period.PeriodId,
|
||||||
|
EntryDate = expense.ExpenseDate,
|
||||||
|
SourceModule = "EXPENSE",
|
||||||
|
Reference = expense.DocNo,
|
||||||
|
Narration = narration,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new GlJournalEntryLineRequest(title.GlExpenseAccountCode, expense.Amount, 0m, narration),
|
||||||
|
new GlJournalEntryLineRequest(_glAccruedExpensesCode, 0m, expense.Amount, narration)
|
||||||
|
]
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
expense.Status = ExpenseStatus.Posted;
|
||||||
|
expense.GlExpenseAccountCode = title.GlExpenseAccountCode;
|
||||||
|
expense.GlJournalNo = posted.JournalNo;
|
||||||
|
expense.GlPostedAt = now;
|
||||||
|
expense.UpdatedAt = now;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseDto> CancelAsync(int expenseId, CancelExpenseRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var expense = await LoadAsync(expenseId, tracking: true, ct)
|
||||||
|
?? throw new NotFoundException($"Expense {expenseId} was not found.");
|
||||||
|
|
||||||
|
if (expense.Status == ExpenseStatus.Cancelled)
|
||||||
|
throw new ConflictException($"Expense {expense.DocNo} is already cancelled.");
|
||||||
|
if (expense.PaidAmount > 0)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseHasPayments, $"Expense {expense.DocNo} has payments recorded and cannot be cancelled.", 409);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
// A Draft never reached GL, so there is nothing to reverse.
|
||||||
|
if (expense.Status == ExpenseStatus.Posted)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(now);
|
||||||
|
var narration = $"Cancellation of expense {expense.DocNo}";
|
||||||
|
var period = await _gl.GetPeriodByDateAsync(today, token);
|
||||||
|
var reversal = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
PeriodId = period.PeriodId,
|
||||||
|
EntryDate = today,
|
||||||
|
SourceModule = "EXPENSE",
|
||||||
|
Reference = expense.DocNo,
|
||||||
|
Narration = narration,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new GlJournalEntryLineRequest(_glAccruedExpensesCode, expense.Amount, 0m, narration),
|
||||||
|
new GlJournalEntryLineRequest(expense.GlExpenseAccountCode ?? string.Empty, 0m, expense.Amount, narration)
|
||||||
|
]
|
||||||
|
}, token);
|
||||||
|
expense.GlReversalJournalNo = reversal.JournalNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
expense.Status = ExpenseStatus.Cancelled;
|
||||||
|
expense.CancelReason = request.Reason.Trim();
|
||||||
|
expense.UpdatedAt = now;
|
||||||
|
return 0;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return Map(expense);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseSummaryReportDto> GetSummaryAsync(DateOnly? from, DateOnly? to, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var start = from ?? new DateOnly(today.Year, today.Month, 1);
|
||||||
|
var end = to ?? start.AddMonths(1).AddDays(-1);
|
||||||
|
if (end < start)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "'to' must not be before 'from'.", 400);
|
||||||
|
|
||||||
|
var posted = _expenses.Query().AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted);
|
||||||
|
|
||||||
|
var inRange = await posted
|
||||||
|
.Where(x => x.ExpenseDate >= start && x.ExpenseDate <= end)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.ExpenseCategoryId, CategoryName = x.Category!.Name, CategorySort = x.Category.SortOrder,
|
||||||
|
x.ExpenseTitleId, TitleName = x.Title!.Name, x.Amount, x.PaidAmount, x.BalanceAmount
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var outstanding = await posted.Where(x => x.BalanceAmount > 0)
|
||||||
|
.Select(x => new { x.BalanceAmount, x.DueDate })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var overdue = outstanding.Where(x => x.DueDate != null && x.DueDate < today).ToList();
|
||||||
|
var draftCount = await _expenses.Query().CountAsync(x => x.Status == ExpenseStatus.Draft, ct);
|
||||||
|
|
||||||
|
var categories = inRange
|
||||||
|
.GroupBy(x => new { x.ExpenseCategoryId, x.CategoryName, x.CategorySort })
|
||||||
|
.OrderBy(g => g.Key.CategorySort).ThenBy(g => g.Key.CategoryName)
|
||||||
|
.Select(g => new ExpenseCategoryTotalDto(
|
||||||
|
g.Key.ExpenseCategoryId, g.Key.CategoryName, g.Sum(x => x.Amount), g.Count(),
|
||||||
|
g.GroupBy(x => new { x.ExpenseTitleId, x.TitleName })
|
||||||
|
.OrderByDescending(t => t.Sum(x => x.Amount))
|
||||||
|
.Select(t => new ExpenseTitleTotalDto(t.Key.ExpenseTitleId, t.Key.TitleName, t.Sum(x => x.Amount), t.Count()))
|
||||||
|
.ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new ExpenseSummaryReportDto(
|
||||||
|
start, end,
|
||||||
|
inRange.Sum(x => x.Amount), inRange.Sum(x => x.PaidAmount), inRange.Sum(x => x.BalanceAmount), inRange.Count,
|
||||||
|
outstanding.Sum(x => x.BalanceAmount), overdue.Sum(x => x.BalanceAmount), overdue.Count, draftCount,
|
||||||
|
categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task<Expense?> LoadAsync(int expenseId, bool tracking, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _expenses.Query()
|
||||||
|
.Include(x => x.Category)
|
||||||
|
.Include(x => x.Title)
|
||||||
|
.Include(x => x.Vendor)
|
||||||
|
.Include(x => x.Payments);
|
||||||
|
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ExpenseTitle> ValidateTitleAsync(ExpenseRequestBase request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Tracked on purpose: UpdateAsync assigns this to the tracked expense's navigations, and
|
||||||
|
// identity resolution hands back the instance the expense already loaded.
|
||||||
|
var title = await _titles.Query().Include(t => t.Category)
|
||||||
|
.FirstOrDefaultAsync(t => t.ExpenseTitleId == request.ExpenseTitleId, ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"Expense title {request.ExpenseTitleId} does not exist.", 422);
|
||||||
|
|
||||||
|
if (title.ExpenseCategoryId != request.ExpenseCategoryId)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseTitleCategoryMismatch, $"Title '{title.Name}' does not belong to the selected category.", 422);
|
||||||
|
if (title.Status != EntityStatus.Active || title.Category?.Status != EntityStatus.Active)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseTitleInactive, $"Expense title '{title.Name}' or its category is inactive.", 422);
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ValidateVendorAsync(int? vendorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (vendorId is null) return null;
|
||||||
|
return await _vendors.Query().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => v.Name).FirstOrDefaultAsync(ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateDates(ExpenseRequestBase request)
|
||||||
|
{
|
||||||
|
// One day of slack, as with payment dates: the client sends its local date.
|
||||||
|
if (request.ExpenseDate > DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Expense date cannot be in the future.", 400);
|
||||||
|
if (request.DueDate is not null && request.DueDate < request.ExpenseDate)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Due date cannot be before the expense date.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(Expense expense, ExpenseRequestBase request, string? vendorName)
|
||||||
|
{
|
||||||
|
expense.ExpenseDate = request.ExpenseDate;
|
||||||
|
expense.DueDate = request.DueDate;
|
||||||
|
expense.ExpenseCategoryId = request.ExpenseCategoryId;
|
||||||
|
expense.ExpenseTitleId = request.ExpenseTitleId;
|
||||||
|
expense.VendorId = request.VendorId;
|
||||||
|
// A linked vendor names the payee unless one was typed explicitly.
|
||||||
|
expense.Payee = Normalize(request.Payee) ?? vendorName;
|
||||||
|
expense.BillReference = Normalize(request.BillReference);
|
||||||
|
expense.Description = Normalize(request.Description);
|
||||||
|
expense.PeriodMonth = Normalize(request.PeriodMonth);
|
||||||
|
expense.Amount = request.Amount;
|
||||||
|
expense.PaidAmount = 0m;
|
||||||
|
expense.BalanceAmount = request.Amount;
|
||||||
|
expense.PaymentStatus = ExpensePaymentStatus.Unpaid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureDraft(Expense expense, string verb)
|
||||||
|
{
|
||||||
|
if (expense.Status != ExpenseStatus.Draft)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status} and cannot be {verb}.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
internal static ExpensePaymentStatus DerivePaymentStatus(decimal paidAmount, decimal balanceAmount) =>
|
||||||
|
balanceAmount <= 0 ? ExpensePaymentStatus.Paid
|
||||||
|
: paidAmount > 0 ? ExpensePaymentStatus.PartiallyPaid
|
||||||
|
: ExpensePaymentStatus.Unpaid;
|
||||||
|
|
||||||
|
private static ExpenseSummaryDto MapSummary(Expense x) => new(
|
||||||
|
x.ExpenseId, x.DocNo, x.BranchId, x.ExpenseDate, x.DueDate,
|
||||||
|
x.ExpenseCategoryId, x.Category?.Name ?? string.Empty, x.ExpenseTitleId, x.Title?.Name ?? string.Empty,
|
||||||
|
x.Payee, x.BillReference, x.PeriodMonth, x.Amount, x.PaidAmount, x.BalanceAmount,
|
||||||
|
x.Status, x.PaymentStatus, x.GlJournalNo, x.CreatedAt);
|
||||||
|
|
||||||
|
internal static ExpenseDto Map(Expense x) => new(
|
||||||
|
x.ExpenseId, x.DocNo, x.BranchId, x.ExpenseDate, x.DueDate,
|
||||||
|
x.ExpenseCategoryId, x.Category?.Name ?? string.Empty, x.ExpenseTitleId, x.Title?.Name ?? string.Empty,
|
||||||
|
x.Payee, x.VendorId, x.Vendor?.Name, x.BillReference, x.Description, x.PeriodMonth,
|
||||||
|
x.Amount, x.PaidAmount, x.BalanceAmount, x.Status, x.PaymentStatus,
|
||||||
|
x.GlExpenseAccountCode, x.GlJournalNo, x.GlPostedAt, x.GlReversalJournalNo, x.CancelReason,
|
||||||
|
x.CreatedBy, x.CreatedAt, x.UpdatedAt,
|
||||||
|
x.Payments.OrderByDescending(p => p.ExpensePaymentId).Select(MapPayment).ToList());
|
||||||
|
|
||||||
|
internal static ExpensePaymentDto MapPayment(ExpensePayment p) => new(
|
||||||
|
p.ExpensePaymentId, p.ExpenseId, p.Amount, p.PaymentDate, p.PaymentMethod,
|
||||||
|
p.GlBankAccountId, p.BankAccountName, p.ChequeNo, p.Reference, p.GlJournalNo, p.CreatedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Expenses;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expense categories and titles. Categories are the fixed seeded four (no create — only their
|
||||||
|
/// active flag changes). Seeded rows (<c>IsSystem</c>) keep their names — they can be
|
||||||
|
/// deactivated, and a title's GL account code can be corrected — while user-added titles
|
||||||
|
/// are fully editable. Nothing is hard-deleted: expenses reference titles, so retiring one
|
||||||
|
/// means setting it Inactive (it stops being offered on new expenses).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExpenseSetupService : IExpenseSetupService
|
||||||
|
{
|
||||||
|
private readonly IRepository<ExpenseCategory> _categories;
|
||||||
|
private readonly IRepository<ExpenseTitle> _titles;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public ExpenseSetupService(IRepository<ExpenseCategory> categories, IRepository<ExpenseTitle> titles, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_categories = categories;
|
||||||
|
_titles = titles;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var categories = await _categories.Query().AsNoTracking()
|
||||||
|
.Include(c => c.Titles)
|
||||||
|
.Where(c => includeInactive || c.Status == EntityStatus.Active)
|
||||||
|
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return categories.Select(c => Map(c, includeInactive)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var category = await _categories.Query().Include(c => c.Titles)
|
||||||
|
.FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct)
|
||||||
|
?? throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (name != category.Name)
|
||||||
|
{
|
||||||
|
if (category.IsSystem)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409);
|
||||||
|
if (await _categories.Query().AnyAsync(c => c.Name == name && c.ExpenseCategoryId != expenseCategoryId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409);
|
||||||
|
category.Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
category.Status = request.Status;
|
||||||
|
category.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Map(category, includeInactive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await _categories.Query().AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct))
|
||||||
|
throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, ct);
|
||||||
|
|
||||||
|
var nextSort = (await _titles.Query().Where(t => t.ExpenseCategoryId == expenseCategoryId)
|
||||||
|
.MaxAsync(t => (int?)t.SortOrder, ct) ?? 0) + 1;
|
||||||
|
var title = new ExpenseTitle
|
||||||
|
{
|
||||||
|
ExpenseCategoryId = expenseCategoryId,
|
||||||
|
Name = name,
|
||||||
|
GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(),
|
||||||
|
IsSystem = false,
|
||||||
|
SortOrder = nextSort,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _titles.AddAsync(title, ct);
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Map(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var title = await _titles.Query().FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct)
|
||||||
|
?? throw new NotFoundException($"Expense title {expenseTitleId} was not found.");
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (name != title.Name)
|
||||||
|
{
|
||||||
|
if (title.IsSystem)
|
||||||
|
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409);
|
||||||
|
await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, ct);
|
||||||
|
title.Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The GL code is editable on system rows too — the seeded codes are placeholders.
|
||||||
|
// Already-posted expenses keep the code they snapshotted at post time.
|
||||||
|
title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim();
|
||||||
|
title.Status = request.Status;
|
||||||
|
title.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Map(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureTitleNameIsUniqueAsync(int expenseCategoryId, string name, int? excludingTitleId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var duplicate = await _titles.Query().AnyAsync(t =>
|
||||||
|
t.ExpenseCategoryId == expenseCategoryId && t.Name == name
|
||||||
|
&& (excludingTitleId == null || t.ExpenseTitleId != excludingTitleId), ct);
|
||||||
|
if (duplicate)
|
||||||
|
throw new DomainException(ErrorCodes.DuplicateExpenseTitle, $"A title named '{name}' already exists in this category.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseCategoryDto Map(ExpenseCategory c, bool includeInactive) => new(
|
||||||
|
c.ExpenseCategoryId, c.Code, c.Name, c.IsSystem, c.Status, c.SortOrder,
|
||||||
|
c.Titles
|
||||||
|
.Where(t => includeInactive || t.Status == EntityStatus.Active)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(Map).ToList());
|
||||||
|
|
||||||
|
private static ExpenseTitleDto Map(ExpenseTitle t) => new(
|
||||||
|
t.ExpenseTitleId, t.ExpenseCategoryId, t.Name, t.GlExpenseAccountCode, t.IsSystem, t.Status, t.SortOrder);
|
||||||
|
}
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
|
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5; docs/DUAL_APPROVAL_PLAN.md §5).</summary>
|
||||||
public interface IAdjustmentService
|
public interface IAdjustmentService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||||
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default);
|
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
|
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Submits an adjustment request — it posts at once only when approvals are switched off.</summary>
|
||||||
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
|
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Gives the next approval; the last one posts the stock movement and GL.</summary>
|
||||||
|
Task<AdjustmentDto> ApproveAsync(int adjustmentId, string? comment, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<AdjustmentDto> RejectAsync(int adjustmentId, string reason, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>The requester withdraws a request that is still pending.</summary>
|
||||||
|
Task<AdjustmentDto> CancelAsync(int adjustmentId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using ERPCore.Dtos.Approvals;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Where a document stands in its approval, as the shared engine needs to see it.</summary>
|
||||||
|
/// <param name="NavCode">The screen whose Approve action an approver needs (e.g. <c>stock.adjustments</c>).</param>
|
||||||
|
/// <param name="CurrentLevel">The approval being given now: 1, or 2 after the first.</param>
|
||||||
|
public sealed record ApprovalTarget(
|
||||||
|
string DocType, int DocId, string DocNo, string NavCode, int RequestedBy, int RequiredApprovals, int CurrentLevel, int? Level1ApprovedBy);
|
||||||
|
|
||||||
|
public enum ApprovalOutcome
|
||||||
|
{
|
||||||
|
AdvancedToLevel2,
|
||||||
|
FullyApproved,
|
||||||
|
Rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared maker–checker rules for stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §4):
|
||||||
|
/// the requester can't approve, the two approvers must differ, level 2 may be limited to roles.
|
||||||
|
/// The Approve permission itself is checked by the endpoint's policy. Decisions are added to the
|
||||||
|
/// caller's unit of work — the document service saves them with its own changes.
|
||||||
|
/// </summary>
|
||||||
|
public interface IApprovalService
|
||||||
|
{
|
||||||
|
Task<ApprovalConfigDto> GetConfigAsync(CancellationToken ct = default);
|
||||||
|
Task<ApprovalConfigDto> UpdateConfigAsync(UpdateApprovalConfigRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Approvals a stock adjustment worth <paramref name="absoluteValue"/> needs (0, 1 or 2).</summary>
|
||||||
|
Task<int> RequiredForStockAdjustmentAsync(decimal absoluteValue, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Approvals a price change of <paramref name="largestChangePercent"/> needs (0, 1 or 2).</summary>
|
||||||
|
Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Validates the current user may decide, and records the decision (unsaved).</summary>
|
||||||
|
Task<ApprovalOutcome> DecideAsync(ApprovalTarget target, bool approve, string? comment, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Null when the current user may decide; otherwise the reason they can't.</summary>
|
||||||
|
Task<string?> WhyCannotDecideAsync(ApprovalTarget target, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Active users who could give <paramref name="target"/>'s current approval — for notifications.</summary>
|
||||||
|
Task<IReadOnlyList<int>> ApproverIdsAsync(ApprovalTarget target, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Notifies whoever can give the target's next approval that it is waiting.</summary>
|
||||||
|
Task NotifyPendingAsync(ApprovalTarget target, string summary, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using ERPCore.Dtos.Accounts;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Till cash in hand per branch and business day (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.1) — the one
|
||||||
|
/// calculation both the deposit form and the Day-End Sales Report read. A branch-scoped caller
|
||||||
|
/// always gets their own branch; <c>branchId</c> null means every branch together.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICashInHandService
|
||||||
|
{
|
||||||
|
/// <summary>The configured <c>BankDeposit:CashTrackingStartDate</c>, if any.</summary>
|
||||||
|
DateOnly? TrackingStartDate { get; }
|
||||||
|
|
||||||
|
/// <summary><paramref name="day"/> null ⇒ today in the business time zone.</summary>
|
||||||
|
Task<CashInHandDto> GetPositionAsync(DateOnly? day, int? branchId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Every day of <c>from</c>..<c>to</c> (at most 92 days), opening carried in from earlier days.</summary>
|
||||||
|
Task<CashInHandDailyDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Same as <see cref="GetDailyAsync"/> without the range cap — for callers that validate their own range.</summary>
|
||||||
|
Task<IReadOnlyList<CashPositionDto>> GetRangeAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The most that can still be deposited against <paramref name="day"/>: the lowest closing balance
|
||||||
|
/// from that day to today, never below zero; zero for a future day or one before tracking starts.
|
||||||
|
/// <paramref name="branchScope"/> is used as given (the caller has already resolved it).
|
||||||
|
/// </summary>
|
||||||
|
Task<decimal> GetAvailableToDepositAsync(DateOnly day, int? branchScope, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bank deposits of till cash — record (posts GL), cancel (reverses GL), list (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2).</summary>
|
||||||
|
public interface IBankDepositService
|
||||||
|
{
|
||||||
|
Task<BankDepositListDto> ListAsync(PageQuery query, BankDepositListFilter filter, CancellationToken ct = default);
|
||||||
|
Task<BankDepositDto?> GetAsync(int bankDepositId, CancellationToken ct = default);
|
||||||
|
Task<BankDepositDto> CreateAsync(CreateBankDepositRequest request, CancellationToken ct = default);
|
||||||
|
Task<BankDepositDto> CancelAsync(int bankDepositId, CancelBankDepositRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ERPCore.Dtos.Reports;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Read-only day-end sales/expense/income report (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
|
||||||
|
public interface IDayEndReportService
|
||||||
|
{
|
||||||
|
/// <summary><paramref name="date"/> null ⇒ today in the report's time zone.</summary>
|
||||||
|
Task<DayEndReportDto> GetDayAsync(DateOnly? date, int? branchId, CancellationToken ct = default);
|
||||||
|
Task<DayEndDailyReportDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Expenses;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Expense categories and their titles (docs/EXPENSE_MODULE_PLAN.md §2.1).</summary>
|
||||||
|
public interface IExpenseSetupService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default);
|
||||||
|
Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default);
|
||||||
|
Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default);
|
||||||
|
Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Expense bills: draft CRUD, post (GL accrual), cancel and reporting (docs/EXPENSE_MODULE_PLAN.md §4).</summary>
|
||||||
|
public interface IExpenseService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ExpenseDto>?> GetAsync(int expenseId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ExpenseDto>> CreateAsync(CreateExpenseRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ExpenseDto>> UpdateAsync(int expenseId, UpdateExpenseRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int expenseId, CancellationToken ct = default);
|
||||||
|
Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default);
|
||||||
|
Task<ExpenseDto> CancelAsync(int expenseId, CancelExpenseRequest request, CancellationToken ct = default);
|
||||||
|
Task<ExpenseSummaryReportDto> GetSummaryAsync(DateOnly? from, DateOnly? to, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Payments against a Posted expense's balance, and the one-step Post & Pay.</summary>
|
||||||
|
public interface IExpensePaymentService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<ExpensePaymentDto>> PayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default);
|
||||||
|
Task<ExpenseDto> PostAndPayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -34,5 +34,8 @@ public interface IItemService
|
|||||||
|
|
||||||
/// <summary>Barcode/RFID scan lookup — item barcode/SKU, or a batch/serial barcode/no
|
/// <summary>Barcode/RFID scan lookup — item barcode/SKU, or a batch/serial barcode/no
|
||||||
/// (optionally scoped to <paramref name="itemId"/> first). Null when nothing matches.</summary>
|
/// (optionally scoped to <paramref name="itemId"/> first). Null when nothing matches.</summary>
|
||||||
|
/// <summary>The item's most recent posted GRN receipts (newest first) — what it was bought at.</summary>
|
||||||
|
Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<ItemScanResultDto?> ScanAsync(string code, int? itemId, CancellationToken ct = default);
|
Task<ItemScanResultDto?> ScanAsync(string code, int? itemId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ public interface INotificationService
|
|||||||
int? relatedEntityId = null,
|
int? relatedEntityId = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same as <see cref="RaiseAsync"/> but delivered only to <paramref name="userIds"/> (active ones) —
|
||||||
|
/// e.g. the approvers of a request, or its requester. Same InApp-channel gate; no-op for an empty list.
|
||||||
|
/// </summary>
|
||||||
|
Task NotifyUsersAsync(
|
||||||
|
IEnumerable<int> userIds,
|
||||||
|
string moduleKey,
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
string? relatedEntityType = null,
|
||||||
|
int? relatedEntityId = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default);
|
Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default);
|
||||||
|
|
||||||
Task MarkAllReadAsync(int userId, CancellationToken ct = default);
|
Task MarkAllReadAsync(int userId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Products;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Selling-price change requests with dual approval (docs/DUAL_APPROVAL_PLAN.md §6).</summary>
|
||||||
|
public interface IPriceAdjustmentService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default);
|
||||||
|
Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Submits a request — applied at once only when price approvals are switched off.</summary>
|
||||||
|
Task<PriceAdjustmentDto> CreateAsync(CreatePriceAdjustmentRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Gives the next approval; the last one writes the new prices to the items.</summary>
|
||||||
|
Task<PriceAdjustmentDto> ApproveAsync(int priceAdjustmentId, string? comment, CancellationToken ct = default);
|
||||||
|
Task<PriceAdjustmentDto> RejectAsync(int priceAdjustmentId, string reason, CancellationToken ct = default);
|
||||||
|
Task<PriceAdjustmentDto> CancelAsync(int priceAdjustmentId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>True when selling prices may only change through an approved price adjustment.</summary>
|
||||||
|
Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ public sealed class ItemService : IItemService
|
|||||||
private readonly IRepository<Brand> _brands;
|
private readonly IRepository<Brand> _brands;
|
||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<TaxRate> _taxRates;
|
private readonly IRepository<TaxRate> _taxRates;
|
||||||
|
private readonly IRepository<ApprovalConfig> _approvalConfig;
|
||||||
|
|
||||||
|
/// <summary>Price approvals on (the default) unless Settings → Approvals set price levels to 0.</summary>
|
||||||
|
private async Task<bool> PricesNeedApprovalAsync(CancellationToken ct)
|
||||||
|
=> (await _approvalConfig.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
|
||||||
|
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
|
||||||
private readonly IRepository<Vendor> _vendors;
|
private readonly IRepository<Vendor> _vendors;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<StockLayer> _stockLayers;
|
private readonly IRepository<StockLayer> _stockLayers;
|
||||||
@@ -59,8 +65,10 @@ public sealed class ItemService : IItemService
|
|||||||
IRepository<Serial> serials,
|
IRepository<Serial> serials,
|
||||||
IProductConfigService config,
|
IProductConfigService config,
|
||||||
INotificationService notifications,
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow,
|
||||||
|
IRepository<ApprovalConfig> approvalConfig)
|
||||||
{
|
{
|
||||||
|
_approvalConfig = approvalConfig;
|
||||||
_items = items;
|
_items = items;
|
||||||
_categories = categories;
|
_categories = categories;
|
||||||
_subCategories = subCategories;
|
_subCategories = subCategories;
|
||||||
@@ -207,6 +215,21 @@ public sealed class ItemService : IItemService
|
|||||||
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
|
||||||
|
throw new NotFoundException($"Item {itemId} was not found.");
|
||||||
|
|
||||||
|
return await _grnLines.Query().AsNoTracking()
|
||||||
|
.Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null)
|
||||||
|
.OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId)
|
||||||
|
.Take(Math.Clamp(take, 1, 50))
|
||||||
|
.Select(l => new ItemPurchasePriceDto(
|
||||||
|
l.GrnId, l.Grn!.DocNo, l.Grn.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name, l.Grn.Warehouse!.Code,
|
||||||
|
l.Qty, l.UnitCost, l.DiscountPct, l.NetUnitCost, l.VatPct))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
|
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking()
|
var item = await _items.Query().AsNoTracking()
|
||||||
@@ -309,6 +332,15 @@ public sealed class ItemService : IItemService
|
|||||||
ErrorCodes.MasterInUse,
|
ErrorCodes.MasterInUse,
|
||||||
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
|
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
|
||||||
|
|
||||||
|
// With price approvals on, selling prices change only through an approved price adjustment
|
||||||
|
// (docs/DUAL_APPROVAL_PLAN.md §6.3). Sending the current prices back unchanged is fine.
|
||||||
|
var pricesChanged = item.SellingMinPrice != request.SellingMinPrice
|
||||||
|
|| item.SellingMaxPrice != request.SellingMaxPrice
|
||||||
|
|| item.SellingFlatPrice != request.SellingFlatPrice;
|
||||||
|
if (pricesChanged && await PricesNeedApprovalAsync(ct))
|
||||||
|
throw new DomainException(ErrorCodes.PriceChangeRequiresApproval,
|
||||||
|
"Selling prices need approval — raise a price adjustment (Products → Price Adjustments) instead of editing them here.", 409);
|
||||||
|
|
||||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(
|
var warrantyPeriodMonths = ValidateWarrantyPeriod(
|
||||||
|
|||||||
@@ -39,16 +39,43 @@ public sealed class NotificationService : INotificationService
|
|||||||
int? relatedEntityId = null,
|
int? relatedEntityId = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var channels = await _config.ResolveEnabledChannelsAsync(moduleKey, ct);
|
|
||||||
if (!channels.Contains(NotificationChannel.InApp)) return;
|
|
||||||
|
|
||||||
var activeUserIds = await _users.Query().AsNoTracking()
|
var activeUserIds = await _users.Query().AsNoTracking()
|
||||||
.Where(u => u.Status == EntityStatus.Active)
|
.Where(u => u.Status == EntityStatus.Active)
|
||||||
.Select(u => u.UserId)
|
.Select(u => u.UserId)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await DeliverAsync(activeUserIds, moduleKey, title, message, relatedEntityType, relatedEntityId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyUsersAsync(
|
||||||
|
IEnumerable<int> userIds,
|
||||||
|
string moduleKey,
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
string? relatedEntityType = null,
|
||||||
|
int? relatedEntityId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var wanted = userIds.Distinct().ToList();
|
||||||
|
if (wanted.Count == 0) return;
|
||||||
|
|
||||||
|
var activeUserIds = await _users.Query().AsNoTracking()
|
||||||
|
.Where(u => u.Status == EntityStatus.Active && wanted.Contains(u.UserId))
|
||||||
|
.Select(u => u.UserId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await DeliverAsync(activeUserIds, moduleKey, title, message, relatedEntityType, relatedEntityId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeliverAsync(
|
||||||
|
List<int> activeUserIds, string moduleKey, string title, string message,
|
||||||
|
string? relatedEntityType, int? relatedEntityId, CancellationToken ct)
|
||||||
|
{
|
||||||
if (activeUserIds.Count == 0) return;
|
if (activeUserIds.Count == 0) return;
|
||||||
|
|
||||||
|
var channels = await _config.ResolveEnabledChannelsAsync(moduleKey, ct);
|
||||||
|
if (!channels.Contains(NotificationChannel.InApp)) return;
|
||||||
|
|
||||||
var notification = new Notification
|
var notification = new Notification
|
||||||
{
|
{
|
||||||
ModuleKey = moduleKey,
|
ModuleKey = moduleKey,
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Products;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6). A request snapshots each item's
|
||||||
|
/// current prices and the prices wanted; nothing changes until it is fully approved (rules in
|
||||||
|
/// <see cref="IApprovalService"/>). Approval then writes the new prices to the items and records an
|
||||||
|
/// <see cref="ItemPriceHistory"/> row — unless an item was repriced since the request was made, in
|
||||||
|
/// which case the approval is refused as stale. One pending request per item at a time.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PriceAdjustmentService : IPriceAdjustmentService
|
||||||
|
{
|
||||||
|
public const string ApprovalDocType = "PADJ";
|
||||||
|
public const string ApprovalNavCode = "products.price-adjustments";
|
||||||
|
|
||||||
|
private readonly IRepository<PriceAdjustment> _adjustments;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<ItemPriceHistory> _history;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
|
private readonly IRepository<ApprovalConfig> _config;
|
||||||
|
private readonly IApprovalService _approvals;
|
||||||
|
private readonly INotificationService _notifications;
|
||||||
|
private readonly INumberSequenceService _numbers;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public PriceAdjustmentService(
|
||||||
|
IRepository<PriceAdjustment> adjustments, IRepository<Item> items, IRepository<ItemPriceHistory> history,
|
||||||
|
IRepository<User> users, IRepository<ApprovalConfig> config, IApprovalService approvals,
|
||||||
|
INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_adjustments = adjustments;
|
||||||
|
_items = items;
|
||||||
|
_history = history;
|
||||||
|
_users = users;
|
||||||
|
_config = config;
|
||||||
|
_approvals = approvals;
|
||||||
|
_notifications = notifications;
|
||||||
|
_numbers = numbers;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _adjustments.Query().AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%"));
|
||||||
|
}
|
||||||
|
if (status is not null) q = q.Where(p => p.Status == status);
|
||||||
|
if (itemId is not null) q = q.Where(p => p.Lines.Any(l => l.ItemId == itemId));
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(p => p.PriceAdjustmentId)
|
||||||
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
|
.Select(p => new PriceAdjustmentSummaryDto(
|
||||||
|
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals,
|
||||||
|
p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : p.Status == PriceAdjustmentStatus.PendingSecondApproval ? 2 : null,
|
||||||
|
p.Lines.Count, p.CreatedBy, p.Creator!.DisplayName, p.CreatedAt, p.AppliedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return PagedResponse<PriceAdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var adjustment = await Load(tracking: false).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct);
|
||||||
|
return adjustment is null ? null : await ToDtoAsync(adjustment, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PriceAdjustmentDto> CreateAsync(CreatePriceAdjustmentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var reason = request.Reason?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(reason))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Give a reason for the price change.", 400);
|
||||||
|
if (request.Lines.Count == 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Add at least one item.", 400);
|
||||||
|
if (request.Lines.Select(l => l.ItemId).Distinct().Count() != request.Lines.Count)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Each item can appear only once in a request.", 400);
|
||||||
|
|
||||||
|
var ids = request.Lines.Select(l => l.ItemId).ToList();
|
||||||
|
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
|
||||||
|
var pendingItemIds = await _adjustments.Query().AsNoTracking()
|
||||||
|
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
|
||||||
|
.SelectMany(p => p.Lines.Select(l => new { l.ItemId, p.DocNo }))
|
||||||
|
.Where(x => ids.Contains(x.ItemId))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var lines = new List<PriceAdjustmentLine>();
|
||||||
|
var largestChange = 0m;
|
||||||
|
foreach (var input in request.Lines)
|
||||||
|
{
|
||||||
|
if (!items.TryGetValue(input.ItemId, out var item))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||||
|
var clash = pendingItemIds.FirstOrDefault(x => x.ItemId == input.ItemId);
|
||||||
|
if (clash is not null)
|
||||||
|
throw new DomainException(ErrorCodes.PriceAdjustmentItemPending,
|
||||||
|
$"{item.Sku} already has a pending price change ({clash.DocNo}) — approve, reject or withdraw that first.", 409);
|
||||||
|
ValidatePrices(item.Sku, input.NewMinPrice, input.NewMaxPrice, input.NewFlatPrice);
|
||||||
|
if (input.NewMinPrice == item.SellingMinPrice && input.NewMaxPrice == item.SellingMaxPrice && input.NewFlatPrice == item.SellingFlatPrice)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"{item.Sku}: the new prices are the same as the current ones.", 400);
|
||||||
|
|
||||||
|
largestChange = Math.Max(largestChange, Math.Max(
|
||||||
|
ChangePercent(item.SellingMinPrice, input.NewMinPrice),
|
||||||
|
Math.Max(ChangePercent(item.SellingMaxPrice, input.NewMaxPrice), ChangePercent(item.SellingFlatPrice, input.NewFlatPrice))));
|
||||||
|
|
||||||
|
lines.Add(new PriceAdjustmentLine
|
||||||
|
{
|
||||||
|
ItemId = item.ItemId,
|
||||||
|
OldMinPrice = item.SellingMinPrice, OldMaxPrice = item.SellingMaxPrice, OldFlatPrice = item.SellingFlatPrice,
|
||||||
|
NewMinPrice = input.NewMinPrice, NewMaxPrice = input.NewMaxPrice, NewFlatPrice = input.NewFlatPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var required = await _approvals.RequiredForPriceAdjustmentAsync(largestChange, ct);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var adjustment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
var entity = new PriceAdjustment
|
||||||
|
{
|
||||||
|
DocNo = await _numbers.NextAsync(DocumentTypes.PriceAdjustment, token),
|
||||||
|
Reason = reason,
|
||||||
|
Status = required == 0 ? PriceAdjustmentStatus.Approved : PriceAdjustmentStatus.PendingApproval,
|
||||||
|
RequiredApprovals = required,
|
||||||
|
CreatedBy = _currentUser.AuditUserId,
|
||||||
|
CreatedAt = now,
|
||||||
|
Lines = lines
|
||||||
|
};
|
||||||
|
await _adjustments.AddAsync(entity, token);
|
||||||
|
if (required == 0)
|
||||||
|
await ApplyInCurrentTransactionAsync(entity, items, now, token);
|
||||||
|
return entity;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
if (required > 0)
|
||||||
|
await _approvals.NotifyPendingAsync(Target(adjustment), Summary(adjustment), ct);
|
||||||
|
|
||||||
|
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PriceAdjustmentDto> ApproveAsync(int priceAdjustmentId, string? comment, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
|
||||||
|
var target = Target(adjustment);
|
||||||
|
var outcome = await _approvals.DecideAsync(target, approve: true, comment, ct);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (target.CurrentLevel == 1)
|
||||||
|
{
|
||||||
|
adjustment.ApprovedLevel1By = _currentUser.AuditUserId;
|
||||||
|
adjustment.ApprovedLevel1At = now;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
adjustment.ApprovedLevel2By = _currentUser.AuditUserId;
|
||||||
|
adjustment.ApprovedLevel2At = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
if (outcome == ApprovalOutcome.AdvancedToLevel2)
|
||||||
|
{
|
||||||
|
adjustment.Status = PriceAdjustmentStatus.PendingSecondApproval;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var ids = adjustment.Lines.Select(l => l.ItemId).ToList();
|
||||||
|
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, token);
|
||||||
|
adjustment.Status = PriceAdjustmentStatus.Approved;
|
||||||
|
await ApplyInCurrentTransactionAsync(adjustment, items, now, token);
|
||||||
|
return 0;
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome == ApprovalOutcome.AdvancedToLevel2)
|
||||||
|
await _approvals.NotifyPendingAsync(Target(adjustment), Summary(adjustment), ct);
|
||||||
|
else
|
||||||
|
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.approved",
|
||||||
|
$"Approved: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was approved — the new prices are now in effect.",
|
||||||
|
ApprovalDocType, adjustment.PriceAdjustmentId, ct);
|
||||||
|
|
||||||
|
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PriceAdjustmentDto> RejectAsync(int priceAdjustmentId, string reason, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
|
||||||
|
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
|
||||||
|
|
||||||
|
adjustment.Status = PriceAdjustmentStatus.Rejected;
|
||||||
|
adjustment.RejectedBy = _currentUser.AuditUserId;
|
||||||
|
adjustment.RejectedAt = DateTime.UtcNow;
|
||||||
|
adjustment.RejectReason = reason.Trim();
|
||||||
|
await SaveDecisionAsync(adjustment, ct);
|
||||||
|
|
||||||
|
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
|
||||||
|
$"Rejected: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}",
|
||||||
|
ApprovalDocType, adjustment.PriceAdjustmentId, ct);
|
||||||
|
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PriceAdjustmentDto> CancelAsync(int priceAdjustmentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
|
||||||
|
if (adjustment.CreatedBy != _currentUser.AuditUserId)
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
|
||||||
|
|
||||||
|
adjustment.Status = PriceAdjustmentStatus.Cancelled;
|
||||||
|
adjustment.CancelledAt = DateTime.UtcNow;
|
||||||
|
await SaveDecisionAsync(adjustment, ct);
|
||||||
|
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var rows = await _history.Query().AsNoTracking()
|
||||||
|
.Where(h => h.ItemId == itemId)
|
||||||
|
.OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var userIds = rows.SelectMany(r => new int?[] { r.RequestedBy, r.ApprovedLevel1By, r.ApprovedLevel2By }).OfType<int>().Distinct().ToList();
|
||||||
|
var names = await _users.Query().AsNoTracking().Where(u => userIds.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct);
|
||||||
|
var docIds = rows.Select(r => r.PriceAdjustmentId).OfType<int>().Distinct().ToList();
|
||||||
|
var docNos = await _adjustments.Query().AsNoTracking().Where(p => docIds.Contains(p.PriceAdjustmentId)).ToDictionaryAsync(p => p.PriceAdjustmentId, p => p.DocNo, ct);
|
||||||
|
|
||||||
|
string? Name(int? id) => id is int v ? names.GetValueOrDefault(v) : null;
|
||||||
|
return rows.Select(h => new ItemPriceHistoryDto(
|
||||||
|
h.ItemPriceHistoryId, h.ItemId, h.PriceAdjustmentId, h.PriceAdjustmentId is int d ? docNos.GetValueOrDefault(d) : null,
|
||||||
|
h.OldMinPrice, h.OldMaxPrice, h.OldFlatPrice, h.NewMinPrice, h.NewMaxPrice, h.NewFlatPrice,
|
||||||
|
h.RequestedBy, Name(h.RequestedBy), h.ApprovedLevel1By, Name(h.ApprovedLevel1By), h.ApprovedLevel2By, Name(h.ApprovedLevel2By),
|
||||||
|
h.AppliedAt)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default)
|
||||||
|
=> (await _config.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
|
||||||
|
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Writes each line's new prices to its item and records the history. Refuses the whole
|
||||||
|
/// request if any item's prices no longer match what the request was made against.</summary>
|
||||||
|
private async Task ApplyInCurrentTransactionAsync(PriceAdjustment adjustment, IReadOnlyDictionary<int, Item> items, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (var line in adjustment.Lines)
|
||||||
|
{
|
||||||
|
var item = items[line.ItemId];
|
||||||
|
if (item.SellingMinPrice != line.OldMinPrice || item.SellingMaxPrice != line.OldMaxPrice || item.SellingFlatPrice != line.OldFlatPrice)
|
||||||
|
throw new DomainException(ErrorCodes.PriceAdjustmentStale,
|
||||||
|
$"{item.Sku}'s prices changed after {adjustment.DocNo} was requested. Reject this request and raise a new one against the current prices.", 409);
|
||||||
|
|
||||||
|
item.SellingMinPrice = line.NewMinPrice;
|
||||||
|
item.SellingMaxPrice = line.NewMaxPrice;
|
||||||
|
item.SellingFlatPrice = line.NewFlatPrice;
|
||||||
|
item.UpdatedAt = now;
|
||||||
|
|
||||||
|
await _history.AddAsync(new ItemPriceHistory
|
||||||
|
{
|
||||||
|
ItemId = item.ItemId,
|
||||||
|
PriceAdjustment = adjustment, // FK set by EF, also when the request is new
|
||||||
|
OldMinPrice = line.OldMinPrice, OldMaxPrice = line.OldMaxPrice, OldFlatPrice = line.OldFlatPrice,
|
||||||
|
NewMinPrice = line.NewMinPrice, NewMaxPrice = line.NewMaxPrice, NewFlatPrice = line.NewFlatPrice,
|
||||||
|
RequestedBy = adjustment.CreatedBy,
|
||||||
|
ApprovedLevel1By = adjustment.ApprovedLevel1By,
|
||||||
|
ApprovedLevel2By = adjustment.ApprovedLevel2By,
|
||||||
|
AppliedAt = now
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
adjustment.AppliedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidatePrices(string sku, decimal? min, decimal? max, decimal? flat)
|
||||||
|
{
|
||||||
|
if (min < 0 || max < 0 || flat < 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"{sku}: prices cannot be negative.", 400);
|
||||||
|
if (min is not null && max is not null && min > max)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"{sku}: the minimum price is above the maximum.", 400);
|
||||||
|
if (flat is not null && ((min is not null && flat < min) || (max is not null && flat > max)))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"{sku}: the flat price must be between the minimum and maximum.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Size of a change in percent; setting or clearing a price counts as 100%.</summary>
|
||||||
|
public static decimal ChangePercent(decimal? oldPrice, decimal? newPrice)
|
||||||
|
{
|
||||||
|
if (oldPrice == newPrice) return 0m;
|
||||||
|
if (oldPrice is null || newPrice is null || oldPrice == 0) return 100m;
|
||||||
|
return Math.Abs(newPrice.Value - oldPrice.Value) / oldPrice.Value * 100m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<PriceAdjustment> Load(bool tracking)
|
||||||
|
{
|
||||||
|
var q = _adjustments.Query().Include(p => p.Lines).ThenInclude(l => l.Item).Include(p => p.Creator);
|
||||||
|
return tracking ? q : q.AsNoTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PriceAdjustment> LoadPendingAsync(int priceAdjustmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var adjustment = await _adjustments.Query().Include(p => p.Lines)
|
||||||
|
.FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct)
|
||||||
|
?? throw new NotFoundException($"Price adjustment {priceAdjustmentId} was not found.");
|
||||||
|
if (adjustment.Status is not (PriceAdjustmentStatus.PendingApproval or PriceAdjustmentStatus.PendingSecondApproval))
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
|
||||||
|
return adjustment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveDecisionAsync(PriceAdjustment adjustment, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? PendingLevel(PriceAdjustment p) => p.Status switch
|
||||||
|
{
|
||||||
|
PriceAdjustmentStatus.PendingApproval => 1,
|
||||||
|
PriceAdjustmentStatus.PendingSecondApproval => 2,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ApprovalTarget Target(PriceAdjustment p) => new(
|
||||||
|
ApprovalDocType, p.PriceAdjustmentId, p.DocNo, ApprovalNavCode, p.CreatedBy, p.RequiredApprovals, PendingLevel(p) ?? 1, p.ApprovedLevel1By);
|
||||||
|
|
||||||
|
private static string Summary(PriceAdjustment p)
|
||||||
|
=> $"Price adjustment {p.DocNo} ({p.Lines.Count} item{(p.Lines.Count == 1 ? "" : "s")}: {p.Reason})";
|
||||||
|
|
||||||
|
private async Task<PriceAdjustmentDto> ToDtoAsync(PriceAdjustment p, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pending = PendingLevel(p);
|
||||||
|
var whyNot = pending is null ? "Not waiting for approval." : await _approvals.WhyCannotDecideAsync(Target(p), ct);
|
||||||
|
return new PriceAdjustmentDto(
|
||||||
|
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, pending,
|
||||||
|
p.CreatedBy, p.Creator?.DisplayName, p.CreatedAt,
|
||||||
|
p.ApprovedLevel1By, p.ApprovedLevel1At, p.ApprovedLevel2By, p.ApprovedLevel2At,
|
||||||
|
p.RejectedBy, p.RejectedAt, p.RejectReason, p.CancelledAt, p.AppliedAt,
|
||||||
|
p.Lines.OrderBy(l => l.PriceAdjustmentLineId).Select(l => new PriceAdjustmentLineDto(
|
||||||
|
l.PriceAdjustmentLineId, l.ItemId, l.Item?.Sku ?? $"#{l.ItemId}", l.Item?.Name ?? string.Empty,
|
||||||
|
l.OldMinPrice, l.OldMaxPrice, l.OldFlatPrice, l.NewMinPrice, l.NewMaxPrice, l.NewFlatPrice,
|
||||||
|
l.Item?.SellingMinPrice, l.Item?.SellingMaxPrice, l.Item?.SellingFlatPrice)).ToList(),
|
||||||
|
await _approvals.HistoryAsync(ApprovalDocType, p.PriceAdjustmentId, ct),
|
||||||
|
whyNot is null, whyNot,
|
||||||
|
pending is not null && p.CreatedBy == _currentUser.AuditUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
PaymentDate = paymentDate,
|
PaymentDate = paymentDate,
|
||||||
GlBankAccountId = account.AccountId,
|
GlBankAccountId = account.AccountId,
|
||||||
BankAccountName = account.AccountName,
|
BankAccountName = account.AccountName,
|
||||||
|
PaymentMethod = ResolveMethod(request.PaymentMethod, account),
|
||||||
Reference = request.Reference,
|
Reference = request.Reference,
|
||||||
GlJournalNo = posted.JournalNo,
|
GlJournalNo = posted.JournalNo,
|
||||||
CreatedBy = actor,
|
CreatedBy = actor,
|
||||||
@@ -151,16 +152,17 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
|
|
||||||
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
|
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
|
||||||
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
|
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
|
||||||
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
|
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, SalesPaymentMethod Method, decimal Amount)>();
|
||||||
var invIdx = 0;
|
var invIdx = 0;
|
||||||
var invRemaining = ordered[0].BalanceAmount;
|
var invRemaining = ordered[0].BalanceAmount;
|
||||||
foreach (var (line, account) in lines)
|
foreach (var (line, account) in lines)
|
||||||
{
|
{
|
||||||
var lineRemaining = line.Amount;
|
var lineRemaining = line.Amount;
|
||||||
|
var method = ResolveMethod(line.PaymentMethod, account);
|
||||||
while (lineRemaining > 0)
|
while (lineRemaining > 0)
|
||||||
{
|
{
|
||||||
var take = Math.Min(lineRemaining, invRemaining);
|
var take = Math.Min(lineRemaining, invRemaining);
|
||||||
allocations.Add((ordered[invIdx], account, take));
|
allocations.Add((ordered[invIdx], account, method, take));
|
||||||
lineRemaining -= take;
|
lineRemaining -= take;
|
||||||
invRemaining -= take;
|
invRemaining -= take;
|
||||||
if (invRemaining == 0 && invIdx < ordered.Count - 1)
|
if (invRemaining == 0 && invIdx < ordered.Count - 1)
|
||||||
@@ -196,7 +198,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
var entities = new List<SalesInvoicePayment>();
|
var entities = new List<SalesInvoicePayment>();
|
||||||
foreach (var (invoice, account, amount) in allocations)
|
foreach (var (invoice, account, method, amount) in allocations)
|
||||||
{
|
{
|
||||||
var entity = new SalesInvoicePayment
|
var entity = new SalesInvoicePayment
|
||||||
{
|
{
|
||||||
@@ -205,6 +207,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
PaymentDate = paymentDate,
|
PaymentDate = paymentDate,
|
||||||
GlBankAccountId = account.AccountId,
|
GlBankAccountId = account.AccountId,
|
||||||
BankAccountName = account.AccountName,
|
BankAccountName = account.AccountName,
|
||||||
|
PaymentMethod = method,
|
||||||
Reference = request.Reference,
|
Reference = request.Reference,
|
||||||
GlJournalNo = posted.JournalNo,
|
GlJournalNo = posted.JournalNo,
|
||||||
CreatedBy = actor,
|
CreatedBy = actor,
|
||||||
@@ -238,6 +241,13 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The caller's method, or — when omitted (older clients) — the account's kind: money into a
|
||||||
|
/// Cash account is cash, money into a Bank account is taken to be a card payment.</summary>
|
||||||
|
private static SalesPaymentMethod ResolveMethod(SalesPaymentMethod? requested, GlBankAccount account)
|
||||||
|
=> requested ?? (string.Equals(account.AccountType, "Cash", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? SalesPaymentMethod.Cash
|
||||||
|
: SalesPaymentMethod.Card);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
|
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
|
||||||
@@ -248,11 +258,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
.OrderByDescending(p => p.SalesInvoicePaymentId)
|
.OrderByDescending(p => p.SalesInvoicePaymentId)
|
||||||
.Select(p => new SalesInvoicePaymentDto(
|
.Select(p => new SalesInvoicePaymentDto(
|
||||||
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
|
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
|
||||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt, p.PaymentMethod))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SalesInvoicePaymentDto Map(SalesInvoicePayment p) => new(
|
private static SalesInvoicePaymentDto Map(SalesInvoicePayment p) => new(
|
||||||
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
|
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
|
||||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
|
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt, p.PaymentMethod);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ public sealed class SalesMappingService : ISalesMappingService
|
|||||||
slip.TaxTotal,
|
slip.TaxTotal,
|
||||||
slip.GrandTotal,
|
slip.GrandTotal,
|
||||||
slip.PaidAmount,
|
slip.PaidAmount,
|
||||||
slip.BalanceAmount);
|
slip.BalanceAmount,
|
||||||
|
slip.CashAmount,
|
||||||
|
slip.CardAmount);
|
||||||
|
|
||||||
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
|
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
|
||||||
=> new(
|
=> new(
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
Lines = lines
|
Lines = lines
|
||||||
};
|
};
|
||||||
Recalculate(entity, request.PaidAmount);
|
Recalculate(entity, request.PaidAmount, request.CardAmount);
|
||||||
await _slips.AddAsync(entity, token);
|
await _slips.AddAsync(entity, token);
|
||||||
return entity;
|
return entity;
|
||||||
}, ct);
|
}, ct);
|
||||||
@@ -163,7 +163,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
slip.Lines.Clear();
|
slip.Lines.Clear();
|
||||||
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
|
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
|
||||||
Recalculate(slip, request.PaidAmount);
|
Recalculate(slip, request.PaidAmount, request.CardAmount);
|
||||||
slip.UpdatedAt = DateTime.UtcNow;
|
slip.UpdatedAt = DateTime.UtcNow;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -249,9 +249,9 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
/// GrandTotal, BalanceAmount == 0) — the behavior every slip had before partial payment
|
/// GrandTotal, BalanceAmount == 0) — the behavior every slip had before partial payment
|
||||||
/// existed, so omitting it is a no-op change. Must be clamped against the just-recomputed
|
/// existed, so omitting it is a no-op change. Must be clamped against the just-recomputed
|
||||||
/// GrandTotal, not the request's own line totals, since GrandTotal is only known after
|
/// GrandTotal, not the request's own line totals, since GrandTotal is only known after
|
||||||
/// lines are summed here.
|
/// lines are summed here. <paramref name="requestedCardAmount"/> null means all cash.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void Recalculate(SalesSlip slip, decimal? requestedPaidAmount)
|
private static void Recalculate(SalesSlip slip, decimal? requestedPaidAmount, decimal? requestedCardAmount)
|
||||||
{
|
{
|
||||||
slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice);
|
slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice);
|
||||||
slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount);
|
slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount);
|
||||||
@@ -263,8 +263,15 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
throw new DomainException(ErrorCodes.SalesSlipPaidAmountInvalid,
|
throw new DomainException(ErrorCodes.SalesSlipPaidAmountInvalid,
|
||||||
$"paidAmount must be between 0 and the slip's total {slip.GrandTotal}.", 400);
|
$"paidAmount must be between 0 and the slip's total {slip.GrandTotal}.", 400);
|
||||||
|
|
||||||
|
var card = requestedCardAmount ?? 0m;
|
||||||
|
if (card < 0 || card > paid)
|
||||||
|
throw new DomainException(ErrorCodes.SalesCardAmountInvalid,
|
||||||
|
$"cardAmount must be between 0 and the paid amount {paid}.", 400);
|
||||||
|
|
||||||
slip.PaidAmount = paid;
|
slip.PaidAmount = paid;
|
||||||
slip.BalanceAmount = slip.GrandTotal - paid;
|
slip.BalanceAmount = slip.GrandTotal - paid;
|
||||||
|
slip.CardAmount = card;
|
||||||
|
slip.CashAmount = paid - card;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
|
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class FifoCostingService : IFifoCostingService
|
|||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly IGlAccountConfigService _glAccounts;
|
private readonly IGlAccountConfigService _glAccounts;
|
||||||
|
|
||||||
|
/// <param name="lowStock">Records each issue for the after-request low-stock check; optional so callers outside a request (tests) can omit it.</param>
|
||||||
public FifoCostingService(
|
public FifoCostingService(
|
||||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||||
IRepository<JournalEntryStub> journal, ErpDbContext db,
|
IRepository<JournalEntryStub> journal, ErpDbContext db,
|
||||||
@@ -312,6 +313,7 @@ public sealed class FifoCostingService : IFifoCostingService
|
|||||||
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
|
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_lowStock?.Track(itemId, warehouseId, qtyBase);
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user