Compare commits

..

1 Commits

1111 changed files with 1404 additions and 273472 deletions
-106
View File
@@ -1,106 +0,0 @@
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore
run: dotnet restore Backend/ERPCore.sln
- name: Publish
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop service
run: systemctl stop erpcore.service
- name: Copy to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart service
# ConnectionStrings__DefaultConnection is supplied by the systemd unit on the host
# (Environment= or EnvironmentFile=), deliberately not committed: appsettings.json ships
# the key empty and appsettings.Production.json carries no ConnectionStrings section at
# all. If the unit does not set it, startup throws before serving and the Verify step
# below fails the job.
run: systemctl restart erpcore.service
- name: Verify
run: |
# `is-active ... || echo` always exited 0, so a crashed service still reported success.
# Wait a few seconds first: a startup failure (bad or missing connection string) happens
# during MigrateAsync, after systemctl restart has already returned.
sleep 5
if ! systemctl is-active --quiet erpcore.service; then
echo "❌ erpcore.service is not running:"
journalctl -u erpcore.service -n 50 --no-pager
exit 1
fi
echo "✅ Service running"
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
-t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
-8
View File
@@ -29,11 +29,3 @@ yarn-error.log*
.DS_Store
Thumbs.db
.idea/
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
Testing/e2e/playwright-report/
Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
@@ -1,211 +0,0 @@
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"
};
}
@@ -1,134 +0,0 @@
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);
}
}
@@ -1,257 +0,0 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Tests;
/// <summary>Aggregates of the Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §7).</summary>
public class DayEndReportServiceTests
{
private static readonly InMemoryDatabaseRoot Root = new();
// Asia/Colombo is UTC+05:30 all year (no DST), so local midnight is 18:30 UTC the day before.
private static readonly DateOnly Day = new(2026, 10, 8);
private static DateTime UtcAt(int hour, int minute = 0) => new(2026, 10, 8, hour, minute, 0, DateTimeKind.Utc);
[Fact]
public async Task Day_MixOfSlipsBundlesAndInvoices_TotalsAndSplitsByMethod()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(
Slip(1, UtcAt(5), total: 1000, paid: 600, cash: 400, card: 200), // partial, mixed
Slip(2, UtcAt(6), total: 200, paid: 200, cash: 200, card: 0), // all cash
Slip(3, UtcAt(7), total: 999, paid: 999, cash: 999, card: 0, status: SalesSlipStatus.Draft));
db.BundleSales.Add(new BundleSale { BundleSaleId = 1, BundleDate = UtcAt(8), Status = BundleSaleStatus.Posted, GrandTotal = 500, CashAmount = 0, CardAmount = 500 });
db.SalesInvoices.Add(Invoice(1, UtcAt(9), netPayable: 2000, paid: 1500));
db.SalesInvoicePayments.AddRange(
Payment(1, invoiceId: 1, UtcAt(9), SalesPaymentMethod.Cash, 500),
Payment(2, invoiceId: 1, UtcAt(10), SalesPaymentMethod.Card, 1000));
SeedExpenseSetup(db);
db.Expenses.AddRange(
Expense(1, Day, 300, ExpenseStatus.Posted),
Expense(2, Day, 700, ExpenseStatus.Cancelled),
Expense(3, Day.AddDays(1), 50, ExpenseStatus.Posted));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal(3700m, s.TotalSales);
Assert.Equal(2800m, s.PaidAmount);
Assert.Equal(900m, s.DueAmount);
Assert.Equal(1100m, s.PaidByCash);
Assert.Equal(1700m, s.PaidByCard);
Assert.Equal(0m, s.PaidByOther);
Assert.Equal(4, s.SalesCount);
Assert.Equal(1, s.CashSalesCount);
Assert.Equal(1, s.CardSalesCount);
Assert.Equal(2, s.MixedSalesCount);
Assert.Equal(0, s.UnpaidSalesCount);
Assert.Equal(300m, s.ExpenseTotal);
Assert.Equal(3400m, s.Income);
Assert.Single(report.Expenses);
Assert.Equal("Electricity", report.Expenses[0].TitleName);
}
[Fact]
public async Task Day_SaleLateInTheLocalEvening_CountsOnTheLocalDay()
{
await using var db = CreateContext();
// 19:00 UTC on Oct 7 is 00:30 on Oct 8 in Colombo; 18:00 UTC on Oct 8 is 23:30 on Oct 8.
db.SalesSlips.AddRange(
Slip(1, new DateTime(2026, 10, 7, 19, 0, 0, DateTimeKind.Utc), total: 100, paid: 100, cash: 100, card: 0),
Slip(2, new DateTime(2026, 10, 8, 18, 0, 0, DateTimeKind.Utc), total: 40, paid: 40, cash: 40, card: 0),
Slip(3, new DateTime(2026, 10, 8, 19, 0, 0, DateTimeKind.Utc), total: 7, paid: 7, cash: 7, card: 0));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day.AddDays(1), null);
Assert.Equal([0m, 140m, 7m], report.Days.Select(d => d.TotalSales));
Assert.Equal(147m, report.Totals.TotalSales);
}
[Fact]
public async Task Day_PaymentOnAnEarlierInvoice_IsACollectionAndCountsInNetCash()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5).AddDays(-3), netPayable: 800, paid: 250));
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(6), SalesPaymentMethod.Cash, 250));
db.SalesSlips.Add(Slip(1, UtcAt(7), total: 60, paid: 60, cash: 60, card: 0));
SeedExpenseSetup(db);
db.Expenses.Add(Expense(1, Day, 30, ExpenseStatus.Posted));
db.ExpensePayments.Add(new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, Amount = 30, PaymentDate = UtcAt(8), PaymentMethod = ExpensePaymentMethod.Cash });
await db.SaveChangesAsync();
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
Assert.Equal(60m, s.TotalSales);
Assert.Equal(60m, s.PaidAmount);
Assert.Equal(250m, s.EarlierInvoiceCollections);
Assert.Equal(60m + 250m - 30m, s.NetCash);
}
[Fact]
public async Task Day_ShowsBankDepositsAndTheCashLeftInHand()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(
Slip(1, UtcAt(5).AddDays(-1), total: 300, paid: 300, cash: 300, card: 0), // yesterday, never banked
Slip(2, UtcAt(6), total: 1000, paid: 1000, cash: 1000, card: 0));
db.BankDeposits.AddRange(
BankDeposit(1, Day, 800, BankDepositStatus.Posted),
BankDeposit(2, Day, 5000, BankDepositStatus.Cancelled));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal((300m, 1000m, 800m, 500m), (s.OpeningCashInHand, s.NetCash, s.CashDeposited, s.ClosingCashInHand));
var deposit = Assert.Single(report.Deposits);
Assert.Equal(("BD-1", "BOC Current", 800m), (deposit.DocNo, deposit.BankAccountName, deposit.Amount));
var range = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day, null);
Assert.Equal((0m, 800m, 500m), (range.Totals.OpeningCashInHand, range.Totals.CashDeposited, range.Totals.ClosingCashInHand));
}
[Fact]
public async Task Day_Profit_IsSalesLessStockCostLessExpenses_AndCanBeALoss()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 1000, paid: 1000));
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(5), SalesPaymentMethod.Card, 1000));
db.SalesSlips.Add(Slip(1, UtcAt(6), total: 200, paid: 0, cash: 0, card: 0));
db.StockLedger.AddRange(
Ledger(1, DocumentTypes.SalesInvoice, 1, Direction.Out, 600),
Ledger(2, DocumentTypes.SalesInvoice, 1, Direction.Out, 100),
Ledger(3, DocumentTypes.SalesSlip, 1, Direction.Out, 150),
Ledger(4, DocumentTypes.SalesInvoice, 1, Direction.In, 999)); // not a sale's cost
SeedExpenseSetup(db);
db.Expenses.Add(Expense(1, Day, 900, ExpenseStatus.Posted));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal(850m, s.CostOfGoodsSold);
Assert.Equal(350m, s.GrossProfit);
Assert.Equal(-550m, s.NetProfit);
Assert.Equal(2, report.Sales.Count);
var invoice = report.Sales.Single(x => x.DocType == "Invoice");
Assert.Equal(("Card", 700m, 300m), (invoice.PaidBy, invoice.Cost, invoice.GrossProfit));
Assert.Equal("Unpaid", report.Sales.Single(x => x.DocType == "Slip").PaidBy);
}
[Fact]
public async Task Day_UnpaidInvoice_CountsAsUnpaidAndDue()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 450, paid: 0));
await db.SaveChangesAsync();
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
Assert.Equal(1, s.UnpaidSalesCount);
Assert.Equal(450m, s.DueAmount);
}
[Fact]
public async Task Daily_FillsQuietDaysWithZeros()
{
await using var db = CreateContext();
var report = await CreateService(db).GetDailyAsync(Day, Day.AddDays(4), null);
Assert.Equal(5, report.Days.Count);
Assert.All(report.Days, d => Assert.Equal(0m, d.Income));
Assert.Equal(Day.AddDays(4), report.Days[^1].Date);
}
[Theory]
[InlineData(1, 0)] // to before from
[InlineData(0, 92)] // 93 days
public async Task Daily_InvalidRange_IsRejected(int fromOffset, int toOffset)
{
await using var db = CreateContext();
var ex = await Assert.ThrowsAsync<DomainException>(() =>
CreateService(db).GetDailyAsync(Day.AddDays(fromOffset), Day.AddDays(toOffset), null));
Assert.Equal(ErrorCodes.DayEndReportRangeInvalid, ex.Code);
}
// --- helpers ------------------------------------------------------------------------------
private static SalesSlip Slip(int id, DateTime at, decimal total, decimal paid, decimal cash, decimal card,
SalesSlipStatus status = SalesSlipStatus.Posted) => new()
{
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = at, Status = status,
GrandTotal = total, PaidAmount = paid, BalanceAmount = total - paid, CashAmount = cash, CardAmount = card
};
private static SalesInvoice Invoice(int id, DateTime at, decimal netPayable, decimal paid) => new()
{
SalesInvoiceId = id, InvoiceNo = $"INV-{id}", InvoiceDate = at, Status = SalesInvoiceStatus.Posted,
GrandTotal = netPayable, NetPayable = netPayable, PaidAmount = paid, BalanceAmount = netPayable - paid
};
private static SalesInvoicePayment Payment(int id, int invoiceId, DateTime at, SalesPaymentMethod method, decimal amount) => new()
{
SalesInvoicePaymentId = id, SalesInvoiceId = invoiceId, PaymentDate = at, PaymentMethod = method, Amount = amount
};
private static StockLedger Ledger(int id, string docType, int docId, Direction direction, decimal value) => new()
{
LedgerId = id, SourceDocType = docType, SourceDocId = docId, Direction = direction, Value = value, QtyBase = 1, ItemId = 1, WarehouseId = 1
};
private static Expense Expense(int id, DateOnly date, decimal amount, ExpenseStatus status) => new()
{
ExpenseId = id, DocNo = $"EXP-{id}", ExpenseDate = date, Amount = amount, Status = status,
ExpenseCategoryId = 1, ExpenseTitleId = 1
};
private static BankDeposit BankDeposit(int id, DateOnly businessDate, decimal amount, BankDepositStatus status) => new()
{
BankDepositId = id, DocNo = $"BD-{id}", BranchId = 1, BusinessDate = businessDate, DepositDate = businessDate, Amount = amount,
Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current"
};
private static void SeedExpenseSetup(ErpDbContext db)
{
db.ExpenseCategories.Add(new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility" });
db.ExpenseTitles.Add(new ExpenseTitle { ExpenseTitleId = 1, ExpenseCategoryId = 1, Name = "Electricity", GlExpenseAccountCode = "5200-01" });
}
private static DayEndReportService CreateService(ErpDbContext db)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["DayEndReport:TimeZone"] = "Asia/Colombo" })
.Build();
var clock = new BusinessDayClock(config);
var branch = new CurrentBranchContext(new TestCurrentUser());
var cash = new CashInHandService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db), branch, clock, config);
return new DayEndReportService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoice>(db),
new Repository<SalesInvoicePayment>(db), new Repository<StockLedger>(db),
new Repository<Expense>(db), new Repository<BankDeposit>(db), cash, branch, clock);
}
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
}
-388
View File
@@ -1,388 +0,0 @@
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();
}
}
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.20" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ERPCore\ERPCore.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -1,151 +0,0 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Time;
using ERPCore.Tests.TestSupport;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace ERPCore.Tests;
/// <summary>
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
/// </summary>
public class GlAccountCodeValidatorTests
{
[Fact]
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
{
var validator = CreateValidator(
new(),
new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100"),
StubGeneralLedgerService.Postable("2000")
]),
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
{
var validator = CreateValidator(
new(),
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
{
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
(GlAccountRole.Inventory, "1100"),
(GlAccountRole.Cash, "1000-01"),
(GlAccountRole.InventoryLoss, "5100"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("1000-01", ex.Message);
Assert.Contains("5100", ex.Message);
Assert.Contains("2 of 3", ex.Message);
}
/// <summary>
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
/// "everything is missing" would refuse to start on a contract change.
/// </summary>
[Fact]
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
{
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService([]),
(GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
/// <summary>
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
/// mapping — escaped a hosted service and aborted the host.
/// </summary>
[Theory]
[MemberData(nameof(UnexpectedFailures))]
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
{
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService(failure),
(GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
public static TheoryData<Exception> UnexpectedFailures() =>
[
StubGeneralLedgerService.Unreachable(),
new NullReferenceException("items was null"),
new global::System.Text.Json.JsonException("unexpected shape"),
new InvalidOperationException("something else entirely"),
new HttpRequestException("connection reset"),
new OperationCanceledException(),
];
[Fact]
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
{
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var validator = CreateValidator(new(), gl);
await validator.StartAsync(CancellationToken.None);
Assert.Equal(0, gl.Calls);
}
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
[Fact]
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
{
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
gl,
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
await validator.StartAsync(CancellationToken.None);
Assert.Equal(1, gl.Calls);
}
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
[Fact]
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
{
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 20, 0, 0, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 0, 0, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 29, 59, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
}
private static GlAccountCodeValidator CreateValidator(
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var services = new ServiceCollection()
.AddScoped<IGeneralLedgerService>(_ => gl)
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
.BuildServiceProvider();
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
}
}
@@ -1,234 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
namespace ERPCore.Tests;
/// <summary>
/// The point of this service is that a mapping GL cannot post to never reaches the database.
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
/// existed in GL at all.
/// </summary>
public class GlAccountConfigServiceTests
{
[Fact]
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.Postable("2000", "GRNI")
]);
var service = CreateService(db, gl);
var before = (await service.GetAsync()).RowVersion;
var result = await service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
Assert.True(result.RowVersion > before);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
}
[Fact]
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(422, ex.StatusCode);
Assert.Contains("9999", ex.Message);
Assert.Contains("Clearing", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>
/// GL rejects a journal line against a non-postable account, so storing one only defers the
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
/// </summary>
[Fact]
public async Task UpdateAsync_RejectsANonPostableAccount()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Contains("not postable", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
[Fact]
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
await AssertNothingStoredAsync(db);
}
[Fact]
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(503, ex.StatusCode);
}
[Fact]
public async Task UpdateAsync_RejectsAStaleIfMatch()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
[Fact]
public async Task UpdateAsync_TreatsBlankAsCleared()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var service = CreateService(db, gl);
var rowVersion = (await service.GetAsync()).RowVersion;
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
}
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
[Fact]
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var codes = await service.GetCodesAsync();
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
Assert.Contains("Cogs", ex.Message);
}
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
[Fact]
public async Task GetCodesAsync_ReadsOncePerScope()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var first = await service.GetCodesAsync();
var second = await service.GetCodesAsync();
Assert.Same(first, second);
}
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
[Fact]
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var result = await service.GetAsync();
Assert.True(result.Value.GlUnavailable);
Assert.Empty(result.Value.Options);
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
Assert.Equal("1100", inventory.AccountCode);
}
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
[Fact]
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
{
await using var db = await CreateSeededContextAsync(c =>
{
c.InventoryAccountCode = "1100";
c.CashAccountCode = "1000-01";
});
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
]));
var result = await service.GetAsync();
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
}
/// <summary>The picker must only offer what validation will accept.</summary>
[Fact]
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
new GlAccount("1200", "Retired", 1, true, false)
]));
var options = (await service.GetAsync()).Value.Options;
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
}
private static async Task AssertNothingStoredAsync(ErpDbContext db)
{
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
Assert.Null(row!.InventoryAccountCode);
Assert.Null(row.ClearingAccountCode);
Assert.Null(row.CashAccountCode);
}
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
{
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
configure?.Invoke(config);
db.GlAccountConfig.Add(config);
await db.SaveChangesAsync();
return db;
}
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
}
-366
View File
@@ -1,366 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests;
/// <summary>
/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO
/// SQL: the sales-line check (<see cref="SalesDomainService.ValidateLineTrackingAsync"/>) and the
/// shared stock-document rules (<see cref="SerialTrackingService"/>).
/// </summary>
public class ItemTrackingTests
{
private static readonly InMemoryDatabaseRoot Root = new();
private const int Wh1 = 1;
private const int Wh2 = 2;
private const int SerialItemId = 10;
private const int BatchItemId = 20;
private const int PlainItemId = 30;
// --- sales line validation --------------------------------------------------------------
[Fact]
public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
}
[Fact]
public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
}
[Fact]
public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock()
{
await using var db = await SeedAsync();
// D6: stock received before serial capture — a layer with no serial.
db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow });
await db.SaveChangesAsync();
var sales = CreateSalesDomain(db);
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet<int>());
Assert.Empty(serials);
}
[Fact]
public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
// Serial 3 is in stock at warehouse 2.
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
}
[Fact]
public async Task SalesLine_SoldSerial_IsNotAvailable()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
}
[Fact]
public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var documentSerials = new HashSet<int>();
await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials));
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
}
[Fact]
public async Task SalesLine_ValidSerials_AreReturned()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet<int>());
Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i));
}
[Fact]
public async Task SalesLine_UntrackedItem_RejectsSerials()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task SalesLine_ExpiredBatch_IsBlocked()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code);
}
[Fact]
public async Task SalesLine_BatchOfAnotherItem_IsRejected()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
// --- stock documents (transfers / adjustments / purchase returns) ------------------------
[Fact]
public async Task Outbound_DamagedSerial_OnlyWhenAllowed()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
// Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it...
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveOutboundAsync(
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock }));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
// ...a write-off or purchase return (InStock or Damaged) takes it.
var serials = await tracking.ResolveOutboundAsync(
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock, SerialStatus.Damaged });
Assert.Single(serials);
}
[Fact]
public async Task Inbound_SerialAlreadyInStock_IsADuplicate()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
item, Wh1, 1, new[] { "SN-1" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
}
[Fact]
public async Task Inbound_FractionalQty_IsRejected_ForSerialItems()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
// SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created.
var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet<string>());
await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow);
await db.SaveChangesAsync();
var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync();
Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status));
Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId));
Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ"));
}
[Fact]
public async Task Move_RecordsHistory_WithPreviousWarehouse()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var serial = await db.Serials.FirstAsync(s => s.SerialId == 1);
await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow);
await db.SaveChangesAsync();
var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1);
Assert.Equal(SerialStatus.InTransit, movement.Status);
Assert.Equal(Wh1, movement.FromWarehouseId);
Assert.Null(movement.WarehouseId);
Assert.Null(serial.WarehouseId);
}
// --- auto-generated serial numbers -------------------------------------------------------
[Fact]
public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers()
{
await using var db = await SeedAsync();
db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold });
// A draft GRN line already holds PH-0042 (not yet a Serial).
db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1,
Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } });
await db.SaveChangesAsync();
var tracking = new ItemTrackingService(db, null!);
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3);
Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next);
}
[Fact]
public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems()
{
await using var db = await SeedAsync();
db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock });
await db.SaveChangesAsync();
var tracking = new ItemTrackingService(db, null!);
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2);
Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next);
}
[Fact]
public async Task NextNumbers_RejectsTooMany()
{
await using var db = await SeedAsync();
var tracking = new ItemTrackingService(db, null!);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
// --- fixtures ---------------------------------------------------------------------------
private static async Task<ErpDbContext> SeedAsync()
{
var db = CreateContext();
db.Items.AddRange(
new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial },
new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch },
new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None });
db.Batches.AddRange(
new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) },
new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) });
db.Serials.AddRange(
new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 },
new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 },
new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 },
new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null },
new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 });
await db.SaveChangesAsync();
return db;
}
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
// Pricing is never touched by the tracking check, so it is not wired.
private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new(
new Repository<Customer>(db), new Repository<Warehouse>(db), new Repository<User>(db),
new Repository<Item>(db), new Repository<TaxRate>(db), null!, new Repository<SalesConfig>(db),
new Repository<Batch>(db), new Repository<Serial>(db), new Repository<StockLayer>(db),
CreateSerialTracking(db));
private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new(
new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), new TestCurrentUser());
private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled)
{
db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled });
await db.SaveChangesAsync();
}
// --- Product Configuration "Serial numbers" switch ---------------------------------------
[Fact]
public async Task SwitchOff_SerialItemSells_WithoutSerials()
{
await using var db = await SeedAsync();
await SwitchSerialNumbersAsync(db, enabled: false);
var sales = CreateSalesDomain(db);
// No pre-tracking stock exists, yet nothing is asked: the item is plain stock now.
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet<int>());
Assert.Empty(serials);
}
[Fact]
public async Task SwitchOff_SerialsSent_AreRefused()
{
await using var db = await SeedAsync();
await SwitchSerialNumbersAsync(db, enabled: false);
var sales = CreateSalesDomain(db);
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var sale = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
var adjustment = await Assert.ThrowsAsync<DomainException>(() =>
tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code);
Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code);
}
[Fact]
public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing()
{
await using var db = await SeedAsync();
Assert.True(await CreateSerialTracking(db).IsEnabledAsync());
}
}
@@ -1,116 +0,0 @@
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();
}
}
@@ -1,152 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Tests;
public class NotificationConfigServiceTests
{
[Fact]
public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = false,
InAppEnabled = true
}
});
var service = CreateService(db);
var result = await service.GetAsync();
Assert.Equal(1u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
[Fact]
public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db);
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = index == 0,
SmsEnabled = index == 1,
InAppEnabled = index == 1
}).ToList()
};
var updated = await service.UpdateAsync(request, 1);
Assert.Equal(2u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Empty(channels);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = true,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
}
[Fact]
public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db, new ThrowingUnitOfWork());
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
{
var configRepo = new Repository<NotificationConfig>(db);
var moduleRepo = new Repository<NotificationConfigModule>(db);
return new NotificationConfigService(
configRepo,
moduleRepo,
new NotificationModuleCatalog(),
new TestCurrentUser(),
uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
}
}
@@ -1,17 +0,0 @@
using ERPCore.Services;
namespace ERPCore.Tests;
public class NotificationModuleCatalogTests
{
[Fact]
public void GetAll_ReturnsStableUniqueModuleKeys()
{
var catalog = new NotificationModuleCatalog();
Assert.NotEmpty(catalog.GetAll());
Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
}
@@ -1,124 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Production;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Production;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests;
/// <summary>
/// Covers the 412 translation on the two production services, which save through
/// <c>ExecuteInTransactionAsync</c> rather than a bare <c>SaveChangesAsync</c> and so guard
/// concurrency differently from the inline pattern the other services use.
/// </summary>
public class ProductionConcurrencyTests
{
private static readonly InMemoryDatabaseRoot Root = new();
[Fact]
public async Task TemplateUpdateAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
// Validation runs before the transaction opens, so the referenced item has to exist.
db.Items.Add(new Item
{
ItemId = 1,
Sku = "FG-1",
Name = "Finished good",
CategoryId = 1,
BaseUomId = 1,
Status = EntityStatus.Active
});
await db.SaveChangesAsync();
var service = CreateTemplateService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateAsync(1, SingleStageTemplate(), expectedRowVersion: 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
[Fact]
public async Task RunUpdateStageQuantitiesAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
var service = CreateRunService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateStageQuantitiesAsync(1, 1, new UpdateStageQuantitiesRequest()));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>
/// The smallest graph the validator accepts: one stage (both entry and terminal) with a
/// stock input and a single item-bearing output, and no edges.
/// </summary>
private static SaveTemplateRequest SingleStageTemplate() => new()
{
Code = "TPL-1",
Name = "Template 1",
Stages =
[
new SaveStageRequest
{
Key = "tmp-1",
Name = "Mix",
Inputs =
[
new SaveInputRequest
{
Source = StageInputSource.Stock,
ItemId = 1,
QtyUnit = StageQtyUnit.Pack,
QtyPerBatch = 1m
}
],
Outputs =
[
new SaveOutputRequest { Key = "tmp-o1", Name = "Output", ItemId = 1, QtyPerBatch = 1m }
]
}
]
};
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
private static ProductionTemplateService CreateTemplateService(ErpDbContext db)
=> new(
new Repository<ProductionTemplate>(db), new Repository<TemplateStage>(db),
new Repository<StageInput>(db), new Repository<StageOutput>(db),
new Repository<StageEdge>(db), new Repository<Item>(db),
new Repository<Uom>(db), new Repository<ProductionRun>(db),
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
// The costing, measurement and numbering collaborators are only ever touched inside the
// transaction lambda, which ThrowingTransactionUnitOfWork never invokes.
private static ProductionRunService CreateRunService(ErpDbContext db)
=> new(
new Repository<ProductionRun>(db), new Repository<ProductionTemplate>(db),
new Repository<Warehouse>(db), new Repository<Bin>(db),
new Repository<Item>(db), new Repository<ReasonCode>(db),
fifo: null!, measure: null!, numbers: null!,
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
}
@@ -1,168 +0,0 @@
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
};
}
}
@@ -1,66 +0,0 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Tests.TestSupport;
/// <summary>
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
/// doing so fails loudly rather than against a silently empty fake.
/// </summary>
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
{
private readonly IReadOnlyList<GlAccount> _accounts;
private readonly Exception? _throws;
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
public int Calls { get; private set; }
/// <summary>A postable, active account — the ordinary case.</summary>
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
{
Calls++;
return _throws is not null
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
: Task.FromResult(_accounts);
}
public Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
=> throw new NotSupportedException();
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
=> throw new NotSupportedException();
public static DomainException Unreachable() =>
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
}
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
{
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
=> throw new NotSupportedException();
}
@@ -1,99 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests.TestSupport;
internal sealed class TestCurrentUser : ICurrentUser
{
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
// Unauthenticated, so no role claim and no branch scoping: the three members the
// AuthHex integration added to ICurrentUser.
public string? RoleCode => null;
public bool IsAdmin => false;
public int? BranchId => null;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new NotSupportedException();
}
/// <summary>
/// Fails the transactional path the way a lost optimistic-concurrency race does, so the
/// services that save through <c>ExecuteInTransactionAsync</c> can be covered too. The
/// lambda is never invoked, which is the point: the real <c>UnitOfWork</c> issues its own
/// <c>SaveChangesAsync</c> after the lambda returns, so the catch under test has to sit
/// outside it.
/// </summary>
internal sealed class ThrowingTransactionUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public static ErpDbContext CreateContext(string name)
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(name, Root)
.Options;
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
{
db.Users.Add(new User
{
UserId = User.SystemUserId,
Username = "system",
DisplayName = "System"
});
var config = new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
// No RowVersion here on purpose: ErpDbContext.BumpRowVersions stamps every
// inserted row with 1, so any seeded value is overwritten on save.
};
if (modules is not null)
{
foreach (var module in modules)
{
module.NotificationConfigId = NotificationConfig.SingletonId;
config.Modules.Add(module);
}
}
db.NotificationConfig.Add(config);
await db.SaveChangesAsync();
}
}
-37
View File
@@ -1,37 +0,0 @@
namespace ERPCore.Common.Http;
/// <summary>
/// Encodes the PostgreSQL xmin concurrency token (a <see cref="uint"/>) as an
/// opaque, quoted HTTP ETag and parses <c>If-Match</c> values back. Round-trips
/// via base64 so the value is stable and content-type agnostic
/// (docs/11-BACKEND-PHASE1.md §1.6).
/// </summary>
public static class ETag
{
/// <summary>Quoted ETag string for a row-version token, e.g. <c>"0RsAAA=="</c>.</summary>
public static string From(uint rowVersion)
=> "\"" + Convert.ToBase64String(BitConverter.GetBytes(rowVersion)) + "\"";
/// <summary>Parse an <c>If-Match</c> header value (quoted, optionally weak) to a token.</summary>
public static bool TryParse(string? ifMatch, out uint rowVersion)
{
rowVersion = 0;
if (string.IsNullOrWhiteSpace(ifMatch)) return false;
var v = ifMatch.Trim();
if (v.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) v = v[2..].Trim();
v = v.Trim('"');
try
{
var bytes = Convert.FromBase64String(v);
if (bytes.Length != sizeof(uint)) return false;
rowVersion = BitConverter.ToUInt32(bytes);
return true;
}
catch (FormatException)
{
return false;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace ERPCore.Common.Http;
/// <summary>
/// Pairs a response DTO with the aggregate's current row-version so the controller
/// can emit an <c>ETag</c> header without the token leaking into the JSON body.
/// </summary>
public sealed record ETagged<T>(T Value, uint RowVersion);
@@ -1,32 +0,0 @@
using ERPCore.Common.Http;
using ERPCore.Infra.Auth;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Base for the v1 API controllers. Centralises ETag / If-Match handling
/// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform.
/// Each controller declares its own explicit lowercase <c>[Route]</c> to match
/// the API contract paths (docs/11 §1.1). Every v1 endpoint requires a valid
/// AuthHex token satisfying the ERP door policy (docs/10 A.4).
/// </summary>
[ApiController]
[Produces("application/json")]
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
public abstract class ApiControllerBase : ControllerBase
{
/// <summary>Parse a mandatory <c>If-Match</c> header, or 428 if absent/malformed.</summary>
protected uint RequireIfMatch()
{
var header = Request.Headers.IfMatch.ToString();
if (!ETag.TryParse(header, out var rowVersion))
throw new DomainException("PRECONDITION_REQUIRED", "A valid If-Match header is required for this update.", 428);
return rowVersion;
}
/// <summary>Emit the strong <c>ETag</c> response header for a row-version token.</summary>
protected void SetETag(uint rowVersion) => Response.Headers.ETag = ETag.From(rowVersion);
}
@@ -1,43 +0,0 @@
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));
}
@@ -1,26 +0,0 @@
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Read-only audit trail (FR-X-02; auditor role). Extends the documented §11 API —
/// the audit trail is required (AR-01 compensating control) and read access is the
/// only way to consume it.
/// </summary>
[Route("api/v1/audit-logs")]
public sealed class AuditLogsController : ApiControllerBase
{
private readonly IAuditService _audit;
public AuditLogsController(IAuditService audit) => _audit = audit;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AuditLogDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AuditLogDto>>> List(
[FromQuery] string? entityType, [FromQuery] int? entityId, [FromQuery] int? userId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct));
}
@@ -1,304 +0,0 @@
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Fronts the external AuthHex identity service (API_REFERENCE.md) so the
/// frontend never calls AuthHex directly. Sessions are delivered as httpOnly
/// Secure cookies (docs/02-SECURITY.md §B.2) via <see cref="AuthCookieWriter"/>
/// — response bodies never carry raw tokens. Does not inherit
/// <see cref="ApiControllerBase"/>: most actions here are pre-session and need
/// <see cref="AllowAnonymousAttribute"/>, and the ETag/If-Match handling that
/// base provides doesn't apply to auth flows.
/// </summary>
[ApiController]
[Produces("application/json")]
[Route("api/v1/auth")]
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
public sealed class AuthController : ControllerBase
{
private readonly IAuthUserService _users;
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
private readonly IAuditService _audit;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
_audit = audit;
}
/// <summary>
/// Authoritative current-session info for the frontend: role + the sidebar nav
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
/// </summary>
[HttpGet("me")]
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
return Ok(await _roles.GetMeAsync(roleCode, ct));
}
// ---- Session-issuing (UserManager) ------------------------------------
[HttpPost("register")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<AuthSessionResponse>> Register([FromBody] RegisterRequest request, CancellationToken ct)
{
var result = await _users.RegisterAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<AuthSessionResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
{
var result = await _users.LoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
[HttpPost("login/otp/verify")]
[AllowAnonymous]
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyLoginOtp([FromBody] VerifyOtpForLoginRequest request, CancellationToken ct)
{
var result = await _users.VerifyOtpForLoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
[HttpPost("refresh-token")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<AuthSessionResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken ct)
{
if (!Request.Cookies.TryGetValue(JwtAuthExtensions.RefreshTokenCookie, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
throw new DomainException(ErrorCodes.RefreshTokenMissing, "No refresh session cookie present.", 401);
var result = await _users.RefreshTokenAsync(refreshToken, request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
// ---- Profile / sessions (UserManager) ---------------------------------
[HttpGet("users/{userId:guid}")]
[AllowAnonymous]
[ProducesResponseType(typeof(GetUserDetailsResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<GetUserDetailsResponse>> GetUserDetails(Guid userId, CancellationToken ct)
=> Ok(await _users.GetUserDetailsAsync(userId, ct));
[HttpGet("sessions")]
[ProducesResponseType(typeof(List<SessionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SessionDto>>> GetSessions(CancellationToken ct)
=> Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct));
[HttpPost("status")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct)
{
await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct);
return NoContent();
}
[HttpPost("lock")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Lock([FromBody] LockUserAccountRequest request, CancellationToken ct)
{
await _users.LockUserAccountAsync(request, RequireBearerToken(), ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
[HttpPost("change-password")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
{
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
[HttpPost("verify-password")]
[ProducesResponseType(typeof(VerifyPasswordResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
/// <summary>
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
/// <para>
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
/// </para>
/// <para>
/// The cookies are cleared even if the upstream revoke fails or no user can be
/// resolved: a logout that leaves the caller holding a live session cookie is worse
/// than one that leaves a stale session server-side (which lapses on its own).
/// </para>
/// </summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
{
var userId = request?.UserId ?? ResolveTokenUserId();
if (userId is not null)
{
try
{
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
}
catch (DomainException)
{
// Upstream unreachable or already-revoked — fall through and clear anyway.
}
}
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
private Guid? ResolveTokenUserId()
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
[HttpPut("me")]
[ValidateCsrf]
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
public async Task<ActionResult<UserSummaryDto?>> UpdateMe([FromBody] UpdateUserRequest request, CancellationToken ct)
=> Ok(await _users.UpdateUserAsync(request, RequireBearerToken(), ct));
// ---- 2FA (UserManager) -------------------------------------------------
[HttpPost("2fa/initiate")]
[ValidateCsrf]
[ProducesResponseType(typeof(TwoFaSetupResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<TwoFaSetupResponse>> InitiateTwoFa(CancellationToken ct)
=> Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct));
[HttpPost("2fa/complete")]
[ValidateCsrf]
[ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<CompleteTwoFaSetupResponse>> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct)
=> Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct));
[HttpPost("2fa/verify")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct)
{
await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct);
return NoContent();
}
[HttpPost("2fa/disable")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> DisableTwoFa([FromBody] DisableTwoFaRequest request, CancellationToken ct)
{
await _users.DisableTwoFaAsync(request, RequireBearerToken(), ct);
return NoContent();
}
[HttpGet("2fa/status")]
[ProducesResponseType(typeof(TwoFaStatusResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<TwoFaStatusResponse>> GetTwoFaStatus(CancellationToken ct)
=> Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct));
// ---- Recovery -----------------------------------------------------------
[HttpPost("recovery/forgot-password")]
[AllowAnonymous]
[ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<ForgotPasswordResponse>> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct)
=> Ok(await _recovery.ForgotPasswordAsync(request, ct));
[HttpPost("recovery/verify-otp")]
[AllowAnonymous]
[ProducesResponseType(typeof(VerifyRecoveryOtpResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<VerifyRecoveryOtpResponse>> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct)
=> Ok(await _recovery.VerifyOtpAsync(request, ct));
[HttpPost("recovery/reset-password")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct)
{
await _recovery.ResetPasswordAsync(request, ct);
return NoContent();
}
[HttpPost("recovery/reset-password-token")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetPasswordWithToken([FromBody] ResetPasswordWithTokenRequest request, CancellationToken ct)
{
await _recovery.ResetPasswordWithTokenAsync(request, ct);
return NoContent();
}
// ---- Availability / OTP (AltOptionManager) -----------------------------
[HttpPost("availability")]
[AllowAnonymous]
[ProducesResponseType(typeof(IsAvailableResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<IsAvailableResponse>> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct)
=> Ok(await _alt.IsAvailableAsync(request, ct));
[HttpPost("otp/send")]
[AllowAnonymous]
[ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<SendOtpResponse>> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
=> Ok(await _alt.SendOtpAsync(request, ct));
[HttpPost("otp/verify")]
[AllowAnonymous]
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyAltOtp([FromBody] VerifyAltOtpRequest request, CancellationToken ct)
{
var result = await _alt.VerifyOtpAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
// ---- Helpers --------------------------------------------------------------
/// <summary>The token that authenticated this request — Bearer header if present, else the session cookie.</summary>
private string RequireBearerToken()
{
var header = Request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..];
if (Request.Cookies.TryGetValue(JwtAuthExtensions.AccessTokenCookie, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
return cookieToken;
// [Authorize] already guaranteed one of the above was present to authenticate this request.
throw new DomainException(ErrorCodes.AuthUpstreamError, "No bearer token found on an authenticated request.", 500);
}
}
@@ -1,78 +0,0 @@
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));
}
@@ -1,47 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Branch configuration endpoints — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only, same
/// Phase-1 posture as <see cref="ProductConfigController"/> — any ERP-admitted user can
/// flip this flag until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/branch-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class BranchConfigController : ApiControllerBase
{
private readonly IBranchConfigService _config;
public BranchConfigController(IBranchConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BranchConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchConfigDto>> Update(
[FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,70 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
[Route("api/v1/brands")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class BrandsController : ApiControllerBase
{
private readonly IBrandService _brands;
public BrandsController(IBrandService brands) => _brands = brands;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BrandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BrandDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _brands.ListAsync(query, status, ct));
[HttpGet("{brandId:int}")]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BrandDto>> GetById(int brandId, CancellationToken ct)
{
var result = await _brands.GetAsync(brandId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BrandDto>> Create([FromBody] CreateBrandRequest request, CancellationToken ct)
{
var result = await _brands.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value);
}
[HttpPut("{brandId:int}")]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BrandDto>> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _brands.UpdateAsync(brandId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{brandId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
{
await _brands.SetStatusAsync(brandId, request.Status, ct);
return NoContent();
}
}
@@ -1,91 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
[HttpGet("templates")]
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
[FromQuery] PageQuery query,
CancellationToken ct)
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
[HttpGet("templates/{bundleSaleTemplateId:int}")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
{
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("templates")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
{
var result = await _bundles.CreateTemplateAsync(request, ct);
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] BundleSaleStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
{
var result = await _bundles.GetAsync(bundleSaleId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{bundleSaleId:int}/posting-check")]
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
[HttpPost]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
{
var result = await _bundles.CreateAsync(request, ct);
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
}
[HttpPut("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
{
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
}
[HttpPost("{bundleSaleId:int}/post")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
[HttpPost("{bundleSaleId:int}/cancel")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
}
@@ -1,99 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories
/// nested beneath each category. The hierarchy is exactly two levels deep — the old
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
/// </summary>
[Route("api/v1/categories")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class CategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
public CategoriesController(ICategoryService categories) => _categories = categories;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListAsync(query, status, ct));
[HttpGet("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
{
var result = await _categories.GetAsync(categoryId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
{
var result = await _categories.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
}
[HttpPut("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CategoryDto>> Update(
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{categoryId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
{
await _categories.SetStatusAsync(categoryId, request.Status, ct);
return NoContent();
}
// Subcategories — nested under their parent category (docs/11 §2.3).
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
[HttpGet("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
[HttpPost("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
{
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
}
}
@@ -1,74 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
[Route("api/v1/customers")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class CustomersController : ApiControllerBase
{
private readonly ICustomerService _customers;
public CustomersController(ICustomerService customers) => _customers = customers;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CustomerDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false,
[FromQuery] int? branchId = null)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CustomerDto>> GetById(int customerId, CancellationToken ct)
{
var result = await _customers.GetAsync(customerId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
{
var result = await _customers.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
}
[HttpPut("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CustomerDto>> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _customers.UpdateAsync(customerId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{customerId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
{
await _customers.SetStatusAsync(customerId, request.Status, ct);
return NoContent();
}
}
@@ -1,23 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Dashboard;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
[Route("api/v1/dashboard")]
[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
}
@@ -1,31 +0,0 @@
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));
}
@@ -1,57 +0,0 @@
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));
}
@@ -1,127 +0,0 @@
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));
}
@@ -1,81 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
/// the existing sales-slip CRUD surface under a more business-friendly route.
/// </summary>
[Route("api/v1/free-issues")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class FreeIssuesController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FreeIssueDto>> GetById(int freeIssueId, CancellationToken ct)
{
var result = await _slips.GetFreeIssueAsync(freeIssueId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{freeIssueId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
[HttpPost]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
public async Task<ActionResult<FreeIssueDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<FreeIssueDto>> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{freeIssueId:int}/post")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.PostAsync(freeIssueId, ct));
[HttpPost("{freeIssueId:int}/cancel")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
}
@@ -1,46 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Generic reverse proxy into the external General Ledger service — forwards every
/// method/path/query/body under this prefix verbatim via
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
/// body) unchanged. No endpoint-specific shape lives here; see
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
/// only and is never exposed to the frontend.
/// </summary>
[Route("api/v1/gl")]
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class GeneralLedgerController : ApiControllerBase
{
private readonly IGeneralLedgerService _gl;
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
[HttpGet("{**path}")]
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
[HttpPost("{**path}")]
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
StatusCode = result.StatusCode,
Content = result.Body,
ContentType = result.ContentType ?? "application/json"
};
}
}
@@ -1,53 +0,0 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Role → GL account mapping endpoints.
/// <para>
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
/// and the server-side validation work from the same list.
/// </para>
/// </summary>
[Route("api/v1/gl-account-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class GlAccountConfigController : ApiControllerBase
{
private readonly IGlAccountConfigService _config;
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
/// not be reached, since a mapping that could not be verified is not stored.
/// </summary>
[HttpPut]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<GlAccountConfigDto>> Update(
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
/// <para>
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
/// Phase-1 posture as <see cref="SalesConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/grn-config")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnConfigController : ApiControllerBase
{
private readonly IGrnConfigService _config;
public GrnConfigController(IGrnConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<GrnConfigDto>> Update(
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,68 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
[Route("api/v1/grns")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
public GrnsController(IGrnService grns)
{
_grns = grns;
}
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GrnDto>> GetById(int grnId, CancellationToken ct)
{
var dto = await _grns.GetAsync(grnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create a Draft GRN against a PO or direct. Cost is PO-derived for PO lines.</summary>
[HttpPost]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<GrnDto>> Create([FromBody] CreateGrnRequest request, CancellationToken ct)
{
var dto = await _grns.CreateAsync(request, ct);
return Created($"/api/v1/grns/{dto.GrnId}", dto);
}
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
[HttpPost("{grnId:int}/confirm")]
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GrnConfirmResultDto>> Confirm(
int grnId, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken ct)
=> Ok(await _grns.ConfirmAsync(grnId, idempotencyKey, ct));
/// <summary>Release or reject an inspection-hold line (FR-GRN-05).</summary>
[HttpPost("{grnId:int}/lines/{grnLineId:int}/release")]
[ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ReleaseLineResultDto>> Release(
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
}
@@ -1,99 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
private readonly IAttendanceUploadService _attendance;
private readonly ICurrentUser _currentUser;
public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser)
{
_attendance = attendance;
_currentUser = currentUser;
}
[HttpGet("template.xlsx")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult DownloadTemplate([FromQuery] string? format)
{
var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase));
return File(content, contentType, fileName);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AttendanceUploadBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AttendanceUploadBatchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status,
[FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct)
=> Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct));
[HttpGet("{batchId:int}")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AttendanceUploadBatchDto>> GetById(int batchId, CancellationToken ct)
{
var result = await _attendance.GetBatchAsync(batchId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Upload(
[FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct);
return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result);
}
[HttpGet("{batchId:int}/records")]
[ProducesResponseType(typeof(List<AttendanceRecordDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceRecordDto>>> ListRecords(
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceRecordDto>> UpdateRecord(
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct)
{
await _attendance.ResolveDuplicateAsync(batchId, request, ct);
return NoContent();
}
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
=> Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
}
@@ -1,74 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/branches")]
public sealed class BranchesController : ApiControllerBase
{
private readonly IBranchService _branches;
public BranchesController(IBranchService branches) => _branches = branches;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BranchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BranchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _branches.ListAsync(query, status, ct));
[HttpGet("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BranchDto>> GetById(int branchId, CancellationToken ct)
{
var result = await _branches.GetAsync(branchId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BranchDto>> Create([FromBody] CreateBranchRequest request, CancellationToken ct)
{
var result = await _branches.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchDto>> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _branches.UpdateAsync(branchId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
{
await _branches.DeleteAsync(branchId, ct);
return NoContent();
}
}
@@ -1,75 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
private readonly IDepartmentService _departments;
public DepartmentsController(IDepartmentService departments) => _departments = departments;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DepartmentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DepartmentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _departments.ListAsync(query, status, ct));
[HttpGet("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DepartmentDto>> GetById(int departmentId, CancellationToken ct)
{
var result = await _departments.GetAsync(departmentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DepartmentDto>> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct)
{
var result = await _departments.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<DepartmentDto>> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _departments.UpdateAsync(departmentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
{
await _departments.DeleteAsync(departmentId, ct);
return NoContent();
}
}
@@ -1,74 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
private readonly IDesignationService _designations;
public DesignationsController(IDesignationService designations) => _designations = designations;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DesignationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DesignationDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _designations.ListAsync(query, status, ct));
[HttpGet("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DesignationDto>> GetById(int designationId, CancellationToken ct)
{
var result = await _designations.GetAsync(designationId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DesignationDto>> Create([FromBody] CreateDesignationRequest request, CancellationToken ct)
{
var result = await _designations.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<DesignationDto>> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _designations.UpdateAsync(designationId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
{
await _designations.DeleteAsync(designationId, ct);
return NoContent();
}
}
@@ -1,227 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
private readonly IEmployeeService _employees;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeDocumentService _documents;
private readonly ILeaveBalanceService _leaveBalances;
private readonly IEmployeeSalaryStructureService _salaryStructures;
private readonly IEmployeeLoanService _loans;
private readonly ICurrentUser _currentUser;
public EmployeesController(
IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents,
ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures,
IEmployeeLoanService loans, ICurrentUser currentUser)
{
_employees = employees;
_links = links;
_documents = documents;
_leaveBalances = leaveBalances;
_salaryStructures = salaryStructures;
_loans = loans;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmployeeListItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmployeeListItemDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status,
[FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeDetailDto>> GetById(int employeeId, CancellationToken ct)
{
var result = await _employees.GetAsync(employeeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDetailDto>> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct)
{
var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmployeeDetailDto>> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct)
{
await _employees.SetStatusAsync(employeeId, request.Status, ct);
return NoContent();
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDeleteResultDto>> Delete(int employeeId, [FromQuery] bool deleteUserAccount, CancellationToken ct)
{
var result = await _employees.DeleteAsync(employeeId, deleteUserAccount, ct);
return Ok(result);
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct)
{
await _links.LinkAsync(employeeId, request.UserId, ct);
return NoContent();
}
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UnlinkUser(int employeeId, CancellationToken ct)
{
await _links.UnlinkAsync(employeeId, ct);
return NoContent();
}
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ReplaceBankDetails(
int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
=> Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<LeaveBalanceDto>>> UpdateLeaveBalances(
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeSalaryStructureDto>> CreateSalaryStructure(
int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct)
{
var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> GetLoan(int employeeId, int loanId, CancellationToken ct)
{
var result = await _loans.GetAsync(employeeId, loanId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct)
{
var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<EmployeeDocumentDto>> UploadDocument(
int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _documents.UploadAsync(
employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadDocument(int employeeId, int documentId, CancellationToken ct)
{
var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct);
return File(content, contentType, fileName);
}
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetDocumentStatus(
int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
{
await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
return NoContent();
}
}
@@ -1,74 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
private readonly IEmploymentTypeService _employmentTypes;
public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmploymentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmploymentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _employmentTypes.ListAsync(query, status, ct));
[HttpGet("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmploymentTypeDto>> GetById(int employmentTypeId, CancellationToken ct)
{
var result = await _employmentTypes.GetAsync(employmentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmploymentTypeDto>> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct)
{
var result = await _employmentTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmploymentTypeDto>> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
{
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
return NoContent();
}
}
@@ -1,74 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
private readonly IHrDocumentTypeService _types;
public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<HrDocumentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<HrDocumentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _types.ListAsync(query, status, ct));
[HttpGet("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<HrDocumentTypeDto>> GetById(int hrDocumentTypeId, CancellationToken ct)
{
var result = await _types.GetAsync(hrDocumentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<HrDocumentTypeDto>> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct)
{
var result = await _types.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<HrDocumentTypeDto>> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
{
await _types.DeleteAsync(hrDocumentTypeId, ct);
return NoContent();
}
}
@@ -1,52 +0,0 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
private readonly IHrReportService _reports;
public HrReportsController(IHrReportService reports) => _reports = reports;
[HttpGet("attendance-summary")]
[ProducesResponseType(typeof(List<AttendanceSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceSummaryRowDto>>> AttendanceSummary(
[FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct)
=> Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct));
[HttpGet("overtime")]
[ProducesResponseType(typeof(List<OvertimeReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<OvertimeReportRowDto>>> Overtime(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
[HttpGet("late-arrivals")]
[ProducesResponseType(typeof(List<LateArrivalReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LateArrivalReportRowDto>>> LateArrivals(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
=> Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
[HttpGet("leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceReportRowDto>>> LeaveBalances([FromQuery] int year, CancellationToken ct)
=> Ok(await _reports.LeaveBalanceReportAsync(year, ct));
[HttpGet("document-expiry")]
[ProducesResponseType(typeof(List<DocumentExpiryReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<DocumentExpiryReportRowDto>>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
=> Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
}
@@ -1,71 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
private readonly ILeaveRequestService _requests;
private readonly ICurrentUser _currentUser;
public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser)
{
_requests = requests;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveRequestDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveRequestDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct)
=> Ok(await _requests.ListAsync(query, employeeId, status, ct));
[HttpGet("{leaveRequestId:int}")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveRequestDto>> GetById(int leaveRequestId, CancellationToken ct)
{
var result = await _requests.GetAsync(leaveRequestId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<LeaveRequestDto>> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct)
{
var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Cancel(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.CancelAsync(leaveRequestId, ct));
}
@@ -1,74 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
private readonly ILeaveTypeService _leaveTypes;
public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _leaveTypes.ListAsync(query, status, ct));
[HttpGet("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveTypeDto>> GetById(int leaveTypeId, CancellationToken ct)
{
var result = await _leaveTypes.GetAsync(leaveTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveTypeDto>> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct)
{
var result = await _leaveTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<LeaveTypeDto>> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
{
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
return NoContent();
}
}
@@ -1,85 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
private readonly IPayrollRunService _payrollRuns;
private readonly ICurrentUser _currentUser;
public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser)
{
_payrollRuns = payrollRuns;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PayrollRunDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PayrollRunDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth,
[FromQuery] PayrollRunStatus? status, CancellationToken ct)
=> Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct));
[HttpGet("{payrollRunId:int}")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollRunDto>> GetById(int payrollRunId, CancellationToken ct)
{
var result = await _payrollRuns.GetAsync(payrollRunId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payrollRunId:int}/lines")]
[ProducesResponseType(typeof(List<PayrollLineDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollLineDto>>> ListLines(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct));
[HttpGet("{payrollRunId:int}/lines/{lineId:int}")]
[ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollLineDetailDto>> GetLine(int payrollRunId, int lineId, CancellationToken ct)
{
var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PayrollRunDto>> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct)
{
var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<PayslipDto>>> GeneratePayslips(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
}
@@ -1,33 +0,0 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
private readonly IPayrollStatutorySettingService _settings;
private readonly ICurrentUser _currentUser;
public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser)
{
_settings = settings;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<PayrollStatutorySettingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
{
var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result);
}
}
@@ -1,32 +0,0 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
private readonly IPayslipService _payslips;
public PayslipsController(IPayslipService payslips) => _payslips = payslips;
[HttpGet("{payslipId:int}")]
[ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayslipDto>> GetById(int payslipId, CancellationToken ct)
{
var result = await _payslips.GetAsync(payslipId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payslipId:int}/view")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> View(int payslipId, CancellationToken ct)
{
var html = await _payslips.RenderHtmlAsync(payslipId, ct);
return html is null ? NotFound() : Content(html, "text/html");
}
}
@@ -1,74 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
private readonly ISalaryComponentService _components;
public SalaryComponentsController(ISalaryComponentService components) => _components = components;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalaryComponentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalaryComponentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _components.ListAsync(query, status, ct));
[HttpGet("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalaryComponentDto>> GetById(int salaryComponentId, CancellationToken ct)
{
var result = await _components.GetAsync(salaryComponentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalaryComponentDto>> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct)
{
var result = await _components.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalaryComponentDto>> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
{
await _components.DeleteAsync(salaryComponentId, ct);
return NoContent();
}
}
@@ -1,28 +0,0 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
private readonly ITaxSlabService _taxSlabs;
public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
[HttpGet]
[ProducesResponseType(typeof(List<TaxSlabDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TaxSlabDto>> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
{
var result = await _taxSlabs.CreateAsync(request, ct);
return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
}
}
@@ -1,74 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
private readonly IWorkShiftService _shifts;
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _shifts.ListAsync(query, status, ct));
[HttpGet("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
{
var result = await _shifts.GetAsync(workShiftId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
{
var result = await _shifts.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
{
await _shifts.DeleteAsync(workShiftId, ct);
return NoContent();
}
}
@@ -1,64 +0,0 @@
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item photos. Same access as the item master itself.</summary>
[Route("api/v1/items/{itemId:int}/images")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemImagesController : ApiControllerBase
{
private readonly IItemImageService _images;
private readonly ICurrentUser _currentUser;
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
{
_images = images;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
=> Ok(await _images.ListAsync(itemId, ct));
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
[HttpPost]
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
}
/// <summary>The image bytes, served inline so an &lt;img&gt; can point straight at it.</summary>
[HttpGet("{itemImageId:int}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
{
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
Response.Headers.CacheControl = "private, max-age=86400";
return File(content, contentType);
}
[HttpDelete("{itemImageId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
{
await _images.DeleteAsync(itemId, itemImageId, ct);
return NoContent();
}
}
@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.ItemTypes;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
/// dimension names. <c>GET</c> is the reason this master exists: it populates the item
/// builder's dropdown. Items never reference an item type; the chosen values are encoded
/// into the client-generated SKU (docs/10 Part C.9).
/// </summary>
[Route("api/v1/item-types")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemTypesController : ApiControllerBase
{
private readonly IItemTypeService _itemTypes;
public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
/// <summary>Feeds the frontend item-builder dropdown; filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ItemTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _itemTypes.ListAsync(query, status, ct));
[HttpGet("{itemTypeId:int}")]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemTypeDto>> GetById(int itemTypeId, CancellationToken ct)
{
var result = await _itemTypes.GetAsync(itemTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ItemTypeDto>> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct)
{
var result = await _itemTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value);
}
[HttpPut("{itemTypeId:int}")]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ItemTypeDto>> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{itemTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
{
await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
return NoContent();
}
}
@@ -1,121 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
[Route("api/v1/items")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
public ItemsController(IItemService items) => _items = items;
/// <summary>List items with optional filters and paging.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ItemListItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemListItemDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
[FromQuery] StockNature? stockNature,
[FromQuery] ItemStockFilter? stock,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")]
[ProducesResponseType(typeof(ItemScanResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemScanResultDto>> Scan([FromQuery] string code, [FromQuery] int? itemId, CancellationToken ct)
{
var result = await _items.ScanAsync(code, itemId, ct);
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>
[HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemDetailDto>> GetById(int itemId, CancellationToken ct)
{
var result = await _items.GetAsync(itemId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Create an item (SKU unique). Server sets status and timestamps.</summary>
[HttpPost]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemDetailDto>> Create([FromBody] CreateItemRequest request, CancellationToken ct)
{
var result = await _items.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/items/{result.Value.ItemId}", result.Value);
}
/// <summary>Full update; requires <c>If-Match</c> (412 on stale ETag).</summary>
[HttpPut("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ItemDetailDto>> Update(int itemId, [FromBody] UpdateItemRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _items.UpdateAsync(itemId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Activate / deactivate the item (FR-MD-08 — deactivate, not delete).</summary>
[HttpPatch("{itemId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct)
{
await _items.SetStatusAsync(itemId, request.Status, ct);
return NoContent();
}
/// <summary>Replace the item's per-warehouse reorder settings (FR-MD-05).</summary>
[HttpPut("{itemId:int}/reorder")]
[ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
}
@@ -1,27 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Read-only GL-ready journal stubs (FR-STK-13; consumed by the Accounting phase).
/// Data only — no posting in Phase 1.
/// </summary>
[Route("api/v1/journal-entries")]
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class JournalEntriesController : ApiControllerBase
{
private readonly IAuditService _audit;
public JournalEntriesController(IAuditService audit) => _audit = audit;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<JournalEntryStubDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<JournalEntryStubDto>>> List(
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, ct));
}
@@ -1,39 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Rbac;
using ERPCore.Repositories.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Controllers;
/// <summary>
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
/// </summary>
[Route("api/v1/nav")]
public sealed class NavController : ApiControllerBase
{
private readonly IRepository<NavItem> _navItems;
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
[HttpGet]
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
{
var items = await _navItems.Query().AsNoTracking()
.Include(n => n.Children)
.OrderBy(n => n.SortOrder)
.ToListAsync(ct);
var dto = items.Select(n => new NavItemDto(
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
n.Children.OrderBy(c => c.SortOrder)
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
.ToList())).ToList();
return Ok(dto);
}
}
@@ -1,37 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Notifications;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notification-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class NotificationConfigController : ApiControllerBase
{
private readonly INotificationConfigService _config;
public NotificationConfigController(INotificationConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<NotificationConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<NotificationConfigDto>> Update([FromBody] UpdateNotificationConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,32 +0,0 @@
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notifications")]
public sealed class NotificationsController : ApiControllerBase
{
private readonly INotificationService _notifications;
private readonly ICurrentUser _currentUser;
public NotificationsController(INotificationService notifications, ICurrentUser currentUser)
{
_notifications = notifications;
_currentUser = currentUser;
}
[HttpGet]
public async Task<ActionResult> Get([FromQuery] int take = 20, CancellationToken ct = default)
{
var feed = await _notifications.GetFeedAsync(_currentUser.AuditUserId, take, ct);
return Ok(feed);
}
[HttpPost("mark-all-read")]
public async Task<ActionResult> MarkAllRead(CancellationToken ct)
{
await _notifications.MarkAllReadAsync(_currentUser.AuditUserId, ct);
return NoContent();
}
}
@@ -1,89 +0,0 @@
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,48 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
/// feature gate for subcategories/brands/item-types.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
/// A dedicated <c>CONFIG_MANAGE</c> permission is reserved for when per-endpoint RBAC
/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute
/// with no other change. Until then any ERP-admitted user can flip these flags; that is
/// the accepted Phase-1 posture, consistent with every other endpoint.
/// </para>
/// </summary>
[Route("api/v1/product-config")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ProductConfigController : ApiControllerBase
{
private readonly IProductConfigService _config;
public ProductConfigController(IProductConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProductConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ProductConfigDto>> Update(
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}}
@@ -1,183 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
[Route("api/v1/production-runs")]
[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] ProductionRunStatus? status,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
/// something, plus a summary total over every filtered row.</summary>
[HttpGet("produced")]
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
[FromQuery] ProductionRunStatus? status,
CancellationToken ct)
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
{
var result = await _runs.GetAsync(runId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
{
var result = await _runs.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
}
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
// --- stage actions (docs/30 §D.3) ---------------------------------------
//
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
// the status guards instead: a double-fire finds the stage already moved on and gets a
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
// deviation from §D.3's "idempotency-key honored".
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<StartStageResultDto>> Start(
int runId, int runStageId,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunStageDto>> Complete(
int runId, int runStageId, [FromBody] CompleteStageRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ApproveStageResultDto>> Approve(
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TransferResultDto>> Transfer(
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
[HttpPost("{runId:int}/cancel")]
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CancelAsync(runId, request, ct));
}
@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _templates.ListAsync(query, status, ct));
[HttpGet("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
{
var result = await _templates.GetAsync(templateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Create(
[FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var result = await _templates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
}
[HttpPut("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Update(
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{templateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
{
await _templates.SetStatusAsync(templateId, request.Status, ct);
return NoContent();
}
}
@@ -1,105 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
[Route("api/v1/purchase-invoices")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseInvoicesController : ApiControllerBase
{
private readonly IPurchaseInvoiceService _invoices;
private readonly IPurchaseInvoicePaymentService _payments;
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
[HttpGet("eligible-grns")]
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
[HttpGet("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
}
[HttpPut("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{purchaseInvoiceId:int}/post")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
[HttpPost("{purchaseInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
}
@@ -1,112 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
[Route("api/v1/purchase-orders")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseOrdersController : ApiControllerBase
{
private readonly IPurchaseOrderService _pos;
public PurchaseOrdersController(IPurchaseOrderService pos) => _pos = pos;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
[HttpGet("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseOrderDto>> GetById(int poId, CancellationToken ct)
{
var result = await _pos.GetAsync(poId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseOrderDto>> Create([FromBody] CreatePurchaseOrderRequest request, CancellationToken ct)
{
var result = await _pos.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/purchase-orders/{result.Value.PoId}", result.Value);
}
/// <summary>Edit while open (FR-PROC-05); requires <c>If-Match</c>. 409 PO_NOT_EDITABLE if closed.</summary>
[HttpPut("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseOrderDto>> Update(int poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _pos.UpdateAsync(poId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
=> Ok(await _pos.SubmitAsync(poId, ct));
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
[HttpDelete("{poId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
{
await _pos.DeleteAsync(poId, ct);
return NoContent();
}
/// <summary>Approve — PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller
/// isn't an Admin or is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise.</summary>
[HttpPost("{poId:int}/approve")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
=> Ok(await _pos.ApproveAsync(poId, ct));
/// <summary>Reject — PendingApproval → Draft, so the requester can revise and resubmit.
/// Same caller restrictions as <see cref="Approve"/>.</summary>
[HttpPost("{poId:int}/reject")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Reject(int poId, [FromBody] RejectPurchaseOrderRequest request, CancellationToken ct)
=> Ok(await _pos.RejectAsync(poId, request.Reason, ct));
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
[HttpPost("{poId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Cancel(int poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct)
=> Ok(await _pos.CancelAsync(poId, request.Reason, ct));
}
@@ -1,47 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
[Route("api/v1/purchase-returns")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseReturnsController : ApiControllerBase
{
private readonly IPurchaseReturnService _returns;
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseReturnDto>> Create([FromBody] CreatePurchaseReturnRequest request, CancellationToken ct)
{
var dto = await _returns.CreateAsync(request, ct);
return Created($"/api/v1/purchase-returns/{dto.ReturnId}", dto);
}
}
@@ -1,31 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Reason-code reference endpoints (docs/11 §6).</summary>
[Route("api/v1/reason-codes")]
public sealed class ReasonCodesController : ApiControllerBase
{
private readonly IReasonCodeService _codes;
public ReasonCodesController(IReasonCodeService codes) => _codes = codes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ReasonCodeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReasonCodeDto>>> List(
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _codes.ListAsync(context, query, ct));
[HttpPost]
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ReasonCodeDto>> Create([FromBody] CreateReasonCodeRequest request, CancellationToken ct)
{
var dto = await _codes.CreateAsync(request, ct);
return Created($"/api/v1/reason-codes/{dto.ReasonCodeId}", dto);
}
}
@@ -1,49 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
[Route("api/v1/requisitions")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RequisitionsController : ApiControllerBase
{
private readonly IRequisitionService _requisitions;
public RequisitionsController(IRequisitionService requisitions) => _requisitions = requisitions;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, status, ct));
[HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RequisitionDto>> GetById(int requisitionId, CancellationToken ct)
{
var dto = await _requisitions.GetAsync(requisitionId, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RequisitionDto>> Create([FromBody] CreateRequisitionRequest request, CancellationToken ct)
{
var dto = await _requisitions.CreateAsync(request, ct);
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
}
[HttpPost("{requisitionId:int}/submit")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RequisitionDto>> Submit(int requisitionId, CancellationToken ct)
=> Ok(await _requisitions.SubmitAsync(requisitionId, ct));
}
@@ -1,61 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>RFQ &amp; quotation endpoints (docs/11 §3.2).</summary>
[Route("api/v1/rfqs")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RfqsController : ApiControllerBase
{
private readonly IRfqService _rfqs;
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
/// <summary>List RFQs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
=> Ok(await _rfqs.ListAsync(query, status, ct));
[HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RfqDto>> GetById(int rfqId, CancellationToken ct)
{
var dto = await _rfqs.GetAsync(rfqId, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RfqDto>> Create([FromBody] CreateRfqRequest request, CancellationToken ct)
{
var dto = await _rfqs.CreateAsync(request, ct);
return Created($"/api/v1/rfqs/{dto.RfqId}", dto);
}
[HttpPost("{rfqId:int}/quotations")]
[ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<VendorQuotationDto>> AddQuotation(int rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct)
{
var dto = await _rfqs.AddQuotationAsync(rfqId, request, ct);
return Created($"/api/v1/rfqs/{rfqId}/quotations/{dto.QuotationId}", dto);
}
[HttpGet("{rfqId:int}/comparison")]
[ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RfqComparisonDto>> Comparison(int rfqId, CancellationToken ct)
=> Ok(await _rfqs.GetComparisonAsync(rfqId, ct));
}
@@ -1,92 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
[Route("api/v1/roles")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class RolesController : ApiControllerBase
{
private readonly IRoleService _roles;
public RolesController(IRoleService roles) => _roles = roles;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _roles.ListAsync(query, status, ct));
[HttpGet("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
{
var result = await _roles.GetAsync(roleId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
{
var result = await _roles.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
}
[HttpPut("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{roleId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
{
await _roles.SetStatusAsync(roleId, request.Status, ct);
return NoContent();
}
[HttpDelete("{roleId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
{
await _roles.DeleteAsync(roleId, ct);
return NoContent();
}
[HttpGet("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
[HttpPut("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
}
@@ -1,46 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
/// <para>
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
/// until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/sales-config")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesConfigController : ApiControllerBase
{
private readonly ISalesConfigService _config;
public SalesConfigController(ISalesConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesConfigDto>> Update(
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,53 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
[Route("api/v1/sales-day-end")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesDayEndController : ApiControllerBase
{
private readonly ISalesDayEndService _dayEnds;
public SalesDayEndController(ISalesDayEndService dayEnds) => _dayEnds = dayEnds;
/// <summary>List day-end closes, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesDayEndSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesDayEndSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? cashierUserId, CancellationToken ct)
=> Ok(await _dayEnds.ListAsync(query, cashierUserId, ct));
/// <summary>What closing this cashier/date right now would include (or the existing close's totals, if already closed).</summary>
[HttpGet("preview")]
[ProducesResponseType(typeof(SalesDayEndPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesDayEndPreviewDto>> Preview(
[FromQuery] int cashierUserId, [FromQuery] DateOnly? businessDate, CancellationToken ct)
=> Ok(await _dayEnds.PreviewAsync(cashierUserId, businessDate ?? DateOnly.FromDateTime(DateTime.UtcNow), ct));
[HttpGet("{salesDayEndId:int}")]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesDayEndDto>> GetById(int salesDayEndId, CancellationToken ct)
{
var dto = await _dayEnds.GetAsync(salesDayEndId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Close the day: lock every Posted slip for (CashierUserId, BusinessDate) and post the
/// consolidated GL journal entry. Idempotent — closing an already-closed date replays it.</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesDayEndDto>> Close([FromBody] CreateSalesDayEndRequest request, CancellationToken ct)
{
var dto = await _dayEnds.CloseAsync(request, ct);
return Created($"/api/v1/sales-day-end/{dto.SalesDayEndId}", dto);
}
}
@@ -1,176 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-invoices")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
private readonly ISalesPricingService _pricing;
private readonly ISalesDomainService _sales;
private readonly IStockService _stock;
public SalesInvoicesController(
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
ISalesDomainService sales, IStockService stock)
{
_invoices = invoices;
_payments = payments;
_pricing = pricing;
_sales = sales;
_stock = stock;
}
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
// items (services) are never short, so they report IsStocked = false and no limit.
[HttpGet("stock-availability")]
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
bool isStocked;
try
{
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
}
catch (InvalidOperationException)
{
return NotFound();
}
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
}
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
// Under "sales" for the same reason as above; items absent from the list have no stock there.
[HttpGet("stock-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
{
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
}
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
// same resolution the server applies on save.
[HttpGet("suggested-price")]
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
try
{
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoiceDto>> GetById(int salesInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(salesInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesInvoiceId:int}/posting-check")]
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
}
[HttpPut("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesInvoiceDto>> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesInvoiceId:int}/post")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
[HttpPost("{salesInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
}
/// <summary>
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
/// </summary>
[HttpPost("payments/bulk")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
=> Ok(await _payments.PayBulkAsync(request, ct));
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> ListPayments(int salesInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(salesInvoiceId, ct));
}
@@ -1,38 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/reports/sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesReportsController : ApiControllerBase
{
private readonly ISalesReportService _reports;
public SalesReportsController(ISalesReportService reports) => _reports = reports;
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
=> Ok(_reports.ListReports());
[HttpGet("{reportId}")]
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
{
var report = _reports.GetReport(reportId);
return report is null ? NotFound() : Ok(report);
}
[HttpPost("query")]
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
{
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
}
}
@@ -1,52 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
[HttpGet("remaining")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (inbound movement).</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
{
var dto = await _returns.CreateAsync(request, ct);
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
}
}
@@ -1,25 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipPromotionsController : ApiControllerBase
{
private readonly ISalesPromotionSuggestionService _suggestions;
public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions;
[HttpGet]
[ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
{
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipsController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public SalesSlipsController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipDto>> GetById(int salesSlipId, CancellationToken ct)
{
var result = await _slips.GetAsync(salesSlipId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesSlipId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesSlipDto>> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesSlipId:int}/post")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.PostAsync(salesSlipId, ct));
[HttpPost("{salesSlipId:int}/cancel")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
}
@@ -1,20 +0,0 @@
using ERPCore.Dtos.Search;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/search")]
public sealed class SearchController : ApiControllerBase
{
private readonly IGlobalSearchService _search;
public SearchController(IGlobalSearchService search) => _search = search;
[HttpGet]
public async Task<ActionResult<IReadOnlyList<GlobalSearchResultDto>>> Get([FromQuery] string q, CancellationToken ct)
{
var results = await _search.SearchAsync(q, 6, ct);
return Ok(results);
}
}
@@ -1,41 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9).</summary>
[Route("api/v1/serials")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class SerialsController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public SerialsController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SerialSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SerialSummaryDto>>> List(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct));
/// <summary>Exact lookup by serial number or barcode, across items.</summary>
[HttpGet("lookup")]
[ProducesResponseType(typeof(IReadOnlyList<SerialDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SerialDetailDto>>> Lookup([FromQuery] string serialNo, CancellationToken ct)
=> Ok(await _tracking.FindSerialAsync(serialNo, ct));
[HttpGet("{serialId:int}")]
[ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SerialDetailDto>> Get(int serialId, CancellationToken ct)
{
var dto = await _tracking.GetSerialAsync(serialId, ct);
return dto is null ? NotFound() : Ok(dto);
}
}
@@ -1,81 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <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")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockAdjustmentsController : ApiControllerBase
{
private const string ApprovePolicy = NavPolicies.Prefix + "stock.adjustments" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
private readonly IAdjustmentService _adjustments;
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List adjustments (posted and requests), newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
[FromQuery] AdjustmentStatus? status, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct));
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
{
var dto = await _adjustments.GetAsync(adjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Submit an adjustment (mandatory reason code). Posts at once only when approvals are off.</summary>
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AdjustmentDto>> Create([FromBody] CreateAdjustmentRequest request, CancellationToken ct)
{
var dto = await _adjustments.CreateAsync(request, ct);
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));
}
@@ -1,86 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
[Route("api/v1/stock")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockController : ApiControllerBase
{
private readonly IStockService _stock;
private readonly IReorderService _reorder;
private readonly IItemTrackingService _tracking;
public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
{
_stock = stock;
_reorder = reorder;
_tracking = tracking;
}
/// <summary>
/// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry
/// first. <c>expiringWithinDays</c> keeps batches expiring within that window, expired included.
/// </summary>
[HttpGet("batches")]
[ProducesResponseType(typeof(PagedResponse<BatchStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BatchStockDto>>> Batches(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct));
[HttpGet("on-hand")]
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
/// this document post?" — the ledger's document reference is polymorphic, so there is
/// no FK to navigate instead (docs/10 C.9).
/// </summary>
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockValuationDto>> Valuation([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetValuationAsync(itemId, warehouseId, ct));
/// <summary>Items at/below their reorder point (FR-STK-10), computed on read.</summary>
[HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
[FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RequisitionDto>> SuggestRequisition(
int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
var dto = await _reorder.CreateSuggestedRequisitionAsync(itemId, warehouseId, ct);
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
}
}
@@ -1,61 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
[Route("api/v1/stock-counts")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockCountsController : ApiControllerBase
{
private readonly ICountService _counts;
public StockCountsController(ICountService counts) => _counts = counts;
/// <summary>List counts, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CountDto>> GetById(int countId, CancellationToken ct)
{
var dto = await _counts.GetAsync(countId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create a count with system quantities snapshotted (immutable).</summary>
[HttpPost]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CountDto>> Create([FromBody] CreateCountRequest request, CancellationToken ct)
{
var dto = await _counts.CreateAsync(request, ct);
return Created($"/api/v1/stock-counts/{dto.CountId}", dto);
}
/// <summary>Enter counted quantities; variance = counted − system.</summary>
[HttpPut("{countId:int}/counts")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CountDto>> EnterCounts(int countId, [FromBody] EnterCountsRequest request, CancellationToken ct)
=> Ok(await _counts.EnterCountsAsync(countId, request, ct));
/// <summary>Post: emit a variance adjustment and close the count.</summary>
[HttpPost("{countId:int}/post")]
[ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CountPostResultDto>> Post(int countId, CancellationToken ct)
=> Ok(await _counts.PostAsync(countId, ct));
}
@@ -1,62 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
[Route("api/v1/stock-transfers")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockTransfersController : ApiControllerBase
{
private readonly ITransferService _transfers;
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
/// <summary>List transfers, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TransferDto>> GetById(int transferId, CancellationToken ct)
{
var dto = await _transfers.GetAsync(transferId, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TransferDto>> Create([FromBody] CreateTransferRequest request, CancellationToken ct)
{
var dto = await _transfers.CreateAsync(request, ct);
return Created($"/api/v1/stock-transfers/{dto.TransferId}", dto);
}
/// <summary>Dispatch: consume source FIFO layers into in-transit. 409 STOCK_NEGATIVE_BLOCKED if short.</summary>
[HttpPost("{transferId:int}/dispatch")]
[ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DispatchResultDto>> Dispatch(int transferId, CancellationToken ct)
=> Ok(await _transfers.DispatchAsync(transferId, ct));
/// <summary>Receive: create the destination layer at the inherited cost (cost-preserving).</summary>
[HttpPost("{transferId:int}/receive")]
[ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ReceiveResultDto>> Receive(int transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
=> Ok(await _transfers.ReceiveAsync(transferId, request, ct));
}
@@ -1,59 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Categories;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
/// Listing and creation live under the parent category on <see cref="CategoriesController"/>,
/// since a subcategory only exists in the context of one.
/// </summary>
[Route("api/v1/subcategories")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class SubCategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
public SubCategoriesController(ICategoryService categories) => _categories = categories;
[HttpGet("{subCategoryId:int}")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SubCategoryDto>> GetById(int subCategoryId, CancellationToken ct)
{
var result = await _categories.GetSubCategoryAsync(subCategoryId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Renames a subcategory. It cannot be moved to another category — see the request DTO.</summary>
[HttpPut("{subCategoryId:int}")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SubCategoryDto>> Update(
int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{subCategoryId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
{
await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
return NoContent();
}
}
@@ -1,70 +0,0 @@
using ERPCore.Dtos.Support;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/support-tickets")]
public sealed class SupportTicketsController : ApiControllerBase
{
private readonly ISupportTicketService _tickets;
private readonly ICurrentUser _currentUser;
public SupportTicketsController(ISupportTicketService tickets, ICurrentUser currentUser)
{
_tickets = tickets;
_currentUser = currentUser;
}
[HttpPost]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SupportTicketDto>> Create([FromBody] CreateSupportTicketRequest request, CancellationToken ct)
{
var result = await _tickets.CreateAsync(_currentUser.AuditUserId, request, ct);
SetETag(result.RowVersion);
return CreatedAtAction(nameof(GetById), new { id = result.Value.TicketId }, result.Value);
}
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SupportTicketSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SupportTicketSummaryDto>>> GetList(CancellationToken ct)
{
var list = await _tickets.GetListAsync(_currentUser.AuditUserId, _currentUser.IsAdmin, ct);
return Ok(list);
}
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SupportTicketDto>> GetById(int id, CancellationToken ct)
{
var result = await _tickets.GetByIdAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{id:int}/messages")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SupportTicketDto>> AddMessage(int id, [FromBody] AddSupportTicketMessageRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _tickets.AddMessageAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut("{id:int}/status")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SupportTicketDto>> UpdateStatus(int id, [FromBody] UpdateSupportTicketStatusRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _tickets.UpdateStatusAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,70 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.TaxRates;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Tax rate master endpoints — see <see cref="Domain.Entities.TaxRate"/>.</summary>
[Route("api/v1/tax-rates")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class TaxRatesController : ApiControllerBase
{
private readonly ITaxRateService _taxRates;
public TaxRatesController(ITaxRateService taxRates) => _taxRates = taxRates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TaxRateDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TaxRateDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _taxRates.ListAsync(query, status, ct));
[HttpGet("{taxRateId:int}")]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TaxRateDto>> GetById(int taxRateId, CancellationToken ct)
{
var result = await _taxRates.GetAsync(taxRateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TaxRateDto>> Create([FromBody] CreateTaxRateRequest request, CancellationToken ct)
{
var result = await _taxRates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/tax-rates/{result.Value.TaxRateId}", result.Value);
}
[HttpPut("{taxRateId:int}")]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TaxRateDto>> Update(int taxRateId, [FromBody] UpdateTaxRateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _taxRates.UpdateAsync(taxRateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{taxRateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int taxRateId, [FromBody] UpdateTaxRateStatusRequest request, CancellationToken ct)
{
await _taxRates.SetStatusAsync(taxRateId, request.Status, ct);
return NoContent();
}
}
@@ -1,64 +0,0 @@
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Serial/batch pickers used across GRN, sales, returns and stock screens
/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need
/// only ERP access — not one module's nav permission — as every one of those screens calls them.
/// </summary>
[Route("api/v1/tracking")]
public sealed class TrackingController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
private readonly ISerialTrackingService _serialTracking;
public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking)
{
_tracking = tracking;
_serialTracking = serialTracking;
}
/// <summary>
/// Which tracking features are switched on (Product Configuration) — readable by every
/// screen, unlike <c>/product-config</c> which needs the Products permission.
/// </summary>
[HttpGet("settings")]
[ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<TrackingSettingsDto>> Settings(CancellationToken ct)
=> Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct)));
/// <summary>
/// Auto-generate: the next <c>count</c> free serial numbers for an item under <c>prefix</c>
/// (continues the highest number already used). Nothing is reserved — the GRN still checks
/// uniqueness when it is saved and confirmed.
/// </summary>
[HttpGet("serials/next-numbers")]
[ProducesResponseType(typeof(IReadOnlyList<string>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<string>>> NextSerialNumbers(
[FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default)
=> Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct));
/// <summary>Issuable serials of an item at a warehouse (search by number/barcode with <c>q</c>).</summary>
[HttpGet("serials/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableSerialDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableSerialDto>>> AvailableSerials(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default)
=> Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct));
/// <summary>Issuable stock per batch with expiry, soonest expiry first.</summary>
[HttpGet("batches/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableBatchDto>>> AvailableBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct));
/// <summary>The FEFO pick posting would make for <c>qty</c> when no batch is chosen.</summary>
[HttpGet("batches/suggest")]
[ProducesResponseType(typeof(IReadOnlyList<BatchSuggestionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<BatchSuggestionDto>>> SuggestBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct)
=> Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct));
}
@@ -1,44 +0,0 @@
using ERPCore.Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
[Route("api/v1/uoms")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class UomsController : ApiControllerBase
{
private readonly IUomService _uoms;
public UomsController(IUomService uoms) => _uoms = uoms;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<UomDto>> Create([FromBody] CreateUomRequest request, CancellationToken ct)
{
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
}
@@ -1,106 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// User management: local shadow `User` list/detail + role assignment, and
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
/// </summary>
[Route("api/v1/users")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
private readonly IAuditService _audit;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
{
_users = users;
_links = links;
_audit = audit;
_currentUser = currentUser;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<EmployeeMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
=> Ok(await _users.ListUserTypesAsync(ct));
[HttpGet("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
{
var result = await _users.GetAsync(userId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
[HttpPut("{userId:int}/role")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
/// <summary>
/// Admin-only escape hatch: permanently deletes a stale AuthHex identity (by email/mobile/NIC/
/// username) that is blocking new registrations. Does not touch ERPCore's own `users` table.
/// See <see cref="IUserManagementService.PurgeAuthHexIdentityAsync"/>.
/// </summary>
[HttpPost("purge-authhex-identity")]
[ProducesResponseType(typeof(DeleteAuthHexUserResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<DeleteAuthHexUserResult>> PurgeAuthHexIdentity([FromBody] PurgeAuthHexIdentityRequest request, CancellationToken ct)
{
if (!_currentUser.IsAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can purge an AuthHex identity.", 403);
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
}
}
@@ -1,79 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
[Route("api/v1/vendors")]
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
public sealed class VendorsController : ApiControllerBase
{
private readonly IVendorService _vendors;
public VendorsController(IVendorService vendors) => _vendors = vendors;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<VendorDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<VendorDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _vendors.ListAsync(query, status, ct));
[HttpGet("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VendorDto>> GetById(int vendorId, CancellationToken ct)
{
var result = await _vendors.GetAsync(vendorId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<VendorDto>> Create([FromBody] CreateVendorRequest request, CancellationToken ct)
{
var result = await _vendors.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
}
[HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<VendorDto>> Update(int vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _vendors.UpdateAsync(vendorId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
{
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent();
}
[HttpDelete("{vendorId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int vendorId, CancellationToken ct)
{
await _vendors.DeleteAsync(vendorId, ct);
return NoContent();
}
}
@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Warehouse &amp; bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
[Route("api/v1/warehouses")]
[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
public sealed class WarehousesController : ApiControllerBase
{
private readonly IWarehouseService _warehouses;
public WarehousesController(IWarehouseService warehouses) => _warehouses = warehouses;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
[HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WarehouseDto>> GetById(int warehouseId, CancellationToken ct)
{
var dto = await _warehouses.GetAsync(warehouseId, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WarehouseDto>> Create([FromBody] CreateWarehouseRequest request, CancellationToken ct)
{
var dto = await _warehouses.CreateAsync(request, ct);
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
}
[HttpPut("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WarehouseDto>> Update(int warehouseId, [FromBody] UpdateWarehouseRequest request, CancellationToken ct)
=> Ok(await _warehouses.UpdateAsync(warehouseId, request, ct));
[HttpDelete("{warehouseId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "warehouses" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int warehouseId, CancellationToken ct)
{
await _warehouses.DeleteAsync(warehouseId, ct);
return NoContent();
}
[HttpGet("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<BinDto>>> ListBins(int warehouseId, CancellationToken ct)
=> Ok(await _warehouses.ListBinsAsync(warehouseId, ct));
[HttpPost("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BinDto>> CreateBin(int warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct)
{
var dto = await _warehouses.CreateBinAsync(warehouseId, request, ct);
return Created($"/api/v1/warehouses/{warehouseId}/bins/{dto.BinId}", dto);
}
}
@@ -1,30 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by
/// posting a sale, never directly — this controller only reads.
/// </summary>
[Route("api/v1/warranties")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class WarrantiesController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarrantyRegistrationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarrantyRegistrationDto>>> List(
[FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo,
[FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, ct));
}
@@ -1,44 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
[Route("api/v1/warranty-periods")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class WarrantyPeriodsController : ApiControllerBase
{
private readonly IWarrantyPeriodService _periods;
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _periods.ListAsync(status, ct));
[HttpPost]
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
{
var result = await _periods.CreateAsync(request.Months!.Value, ct);
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
}
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{warrantyPeriodId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
{
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
return NoContent();
}
}
-38
View File
@@ -1,38 +0,0 @@
namespace ERPCore.Domain;
/// <summary>
/// Document-type prefixes for <see cref="Entities.NumberSequence"/> and the
/// generated document numbers (docs/10 §B.8.2). One prefix per numbered document.
/// </summary>
public static class DocumentTypes
{
public const string Requisition = "PR";
public const string Rfq = "RFQ";
public const string PurchaseOrder = "PO";
public const string Grn = "GRN";
public const string PurchaseInvoice = "PINV";
public const string Transfer = "TRF";
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
public const string Production = "PRD";
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string BundleSaleTemplate = "TPL";
public const string SalesReturn = "SRET";
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
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";
}
@@ -1,40 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/day attendance row (FR-HR-ATT). <see cref="WorkShiftId"/> is
/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md
/// A.3) so a later shift reassignment never retroactively changes historical
/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceRecord
{
public int AttendanceRecordId { get; set; }
public int? AttendanceUploadBatchId { get; set; }
public AttendanceUploadBatch? AttendanceUploadBatch { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime AttendanceDate { get; set; }
public TimeSpan? CheckIn { get; set; }
public TimeSpan? CheckOut { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int WorkingMinutes { get; set; }
public int LateMinutes { get; set; }
public int EarlyLeaveMinutes { get; set; }
public int OvertimeMinutes { get; set; }
public AttendanceStatus AttendanceStatus { get; set; }
public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid;
public int? DuplicateOfAttendanceRecordId { get; set; }
public string? Notes { get; set; }
public bool IsManualOverride { get; set; }
public int? EditedBy { get; set; }
public DateTime? EditedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -1,31 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the
/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly
/// one payroll period, numbered via <see cref="NumberSequence"/> (docType "ATT").
/// Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceUploadBatch
{
public int AttendanceUploadBatchId { get; set; }
public string DocNo { get; set; } = string.Empty;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
public AttendanceSourceType SourceType { get; set; }
public string? OriginalFileName { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft;
public int? ConfirmedBy { get; set; }
public DateTime? ConfirmedAt { get; set; }
public int RowCountTotal { get; set; }
public int RowCountDuplicate { get; set; }
public int RowCountError { get; set; }
public uint RowVersion { get; set; }
}
@@ -1,23 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Immutable audit trail entry (FR-X-02) — the compensating control for the deferred
/// RBAC (02-SECURITY AR-01/B.3). One row per create/update/delete of an audited
/// entity, capturing who / when / what changed (old→new in <see cref="ChangeSet"/>).
/// Written automatically by <c>ErpDbContext.SaveChangesAsync</c>. Append-only at the
/// app level; DB-role revocation of UPDATE/DELETE is deferred hardening (B.3).
/// Model: docs/10 Part C.7.
/// </summary>
public class AuditLog
{
public int AuditId { get; set; }
public int UserId { get; set; }
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public AuditAction Action { get; set; }
/// <summary>JSON change set: field→value (create/delete) or field→{old,new} (update).</summary>
public string ChangeSet { get; set; } = "{}";
public DateTime CreatedAt { get; set; }
}
@@ -1,55 +0,0 @@
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; }
}

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