price adjustment and stock adjustments
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Dtos.Approvals;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10). The inbox is open to every
|
||||
/// signed-in user — it only lists what they may approve and their own requests. Reading the
|
||||
/// settings is open too (screens need to know whether prices are locked); changing them needs
|
||||
/// <c>settings.approvals</c>.
|
||||
/// </summary>
|
||||
[Route("api/v1/approvals")]
|
||||
public sealed class ApprovalsController : ApiControllerBase
|
||||
{
|
||||
private readonly ApprovalInboxService _inbox;
|
||||
private readonly IApprovalService _approvals;
|
||||
|
||||
public ApprovalsController(ApprovalInboxService inbox, IApprovalService approvals)
|
||||
{
|
||||
_inbox = inbox;
|
||||
_approvals = approvals;
|
||||
}
|
||||
|
||||
[HttpGet("pending")]
|
||||
[ProducesResponseType(typeof(PendingApprovalsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PendingApprovalsDto>> Pending(CancellationToken ct)
|
||||
=> Ok(await _inbox.PendingAsync(ct));
|
||||
|
||||
[HttpGet("config")]
|
||||
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApprovalConfigDto>> GetConfig(CancellationToken ct)
|
||||
=> Ok(await _approvals.GetConfigAsync(ct));
|
||||
|
||||
[HttpPut("config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.approvals")]
|
||||
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApprovalConfigDto>> UpdateConfig([FromBody] UpdateApprovalConfigRequest request, CancellationToken ct)
|
||||
=> Ok(await _approvals.UpdateConfigAsync(request, ct));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Accounts;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Till cash deposited to the bank, and the cash in hand behind it (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3).
|
||||
/// Recording needs the Create action on <c>accounts.bank-deposits</c>; cancelling needs Delete.
|
||||
/// </summary>
|
||||
[Route("api/v1/bank-deposits")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits")]
|
||||
public sealed class BankDepositsController : ApiControllerBase
|
||||
{
|
||||
private readonly IBankDepositService _deposits;
|
||||
private readonly ICashInHandService _cash;
|
||||
|
||||
public BankDepositsController(IBankDepositService deposits, ICashInHandService cash)
|
||||
{
|
||||
_deposits = deposits;
|
||||
_cash = cash;
|
||||
}
|
||||
|
||||
/// <summary>Deposits filtered by business date range, branch, bank account and status, plus their total.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(BankDepositListDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BankDepositListDto>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] int? branchId,
|
||||
[FromQuery] long? bankAccountId, [FromQuery] BankDepositStatus? status, CancellationToken ct)
|
||||
=> Ok(await _deposits.ListAsync(query, new BankDepositListFilter(from, to, branchId, bankAccountId, status), ct));
|
||||
|
||||
[HttpGet("{bankDepositId:int}")]
|
||||
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BankDepositDto>> GetById(int bankDepositId, CancellationToken ct)
|
||||
{
|
||||
var result = await _deposits.GetAsync(bankDepositId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>One business day's cash position (defaults to today) and how much of it can still be deposited.</summary>
|
||||
[HttpGet("cash-in-hand")]
|
||||
[ProducesResponseType(typeof(CashInHandDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CashInHandDto>> CashInHand([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _cash.GetPositionAsync(date, branchId, ct));
|
||||
|
||||
/// <summary>Day-wise cash position for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
|
||||
[HttpGet("cash-in-hand/daily")]
|
||||
[ProducesResponseType(typeof(CashInHandDailyDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<CashInHandDailyDto>> CashInHandDaily(
|
||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _cash.GetDailyAsync(from, to, branchId, ct));
|
||||
|
||||
/// <summary>Record a deposit; posts GL (Debit bank / Credit cash). Rejected above the cash in hand.</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<BankDepositDto>> Create([FromBody] CreateBankDepositRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _deposits.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/bank-deposits/{result.BankDepositId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Cancel a Posted deposit; posts the reversing GL entry and puts the cash back in hand.</summary>
|
||||
[HttpPost("{bankDepositId:int}/cancel")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<BankDepositDto>> Cancel(int bankDepositId, [FromBody] CancelBankDepositRequest request, CancellationToken ct)
|
||||
=> Ok(await _deposits.CancelAsync(bankDepositId, request, ct));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Dtos.Reports;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Day-End Sales Report — a day's sales, payments by method, expenses and income, and the
|
||||
/// same day-wise over a range for the income chart/PDF (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
|
||||
[Route("api/v1/day-end-report")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales.day-end-report")]
|
||||
public sealed class DayEndReportController : ApiControllerBase
|
||||
{
|
||||
private readonly IDayEndReportService _reports;
|
||||
|
||||
public DayEndReportController(IDayEndReportService reports) => _reports = reports;
|
||||
|
||||
/// <summary>One business day (defaults to today in the report's time zone).</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(DayEndReportDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DayEndReportDto>> GetDay([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _reports.GetDayAsync(date, branchId, ct));
|
||||
|
||||
/// <summary>Day-wise figures for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
|
||||
[HttpGet("daily")]
|
||||
[ProducesResponseType(typeof(DayEndDailyReportDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<DayEndDailyReportDto>> GetDaily([FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _reports.GetDailyAsync(from, to, branchId, ct));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Dtos.Expenses;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Expense categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup"). Reading is open to
|
||||
/// anyone with Accounts access (the expense form needs the lists); changes need the
|
||||
/// Expense Setup permission. Categories are the fixed seeded four — there is no create
|
||||
/// endpoint; only their active flag can change. Titles can be added under them.
|
||||
/// </summary>
|
||||
[Route("api/v1")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts")]
|
||||
public sealed class ExpenseSetupController : ApiControllerBase
|
||||
{
|
||||
private const string SetupPolicy = NavPolicies.Prefix + "accounts.expense-setup";
|
||||
|
||||
private readonly IExpenseSetupService _setup;
|
||||
|
||||
public ExpenseSetupController(IExpenseSetupService setup) => _setup = setup;
|
||||
|
||||
/// <summary>Categories, each with its titles, in display order.</summary>
|
||||
[HttpGet("expense-categories")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExpenseCategoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<ExpenseCategoryDto>>> List([FromQuery] bool includeInactive, CancellationToken ct)
|
||||
=> Ok(await _setup.ListAsync(includeInactive, ct));
|
||||
|
||||
[HttpPut("expense-categories/{expenseCategoryId:int}")]
|
||||
[Authorize(Policy = SetupPolicy)]
|
||||
[ProducesResponseType(typeof(ExpenseCategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseCategoryDto>> UpdateCategory(int expenseCategoryId, [FromBody] UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||
=> Ok(await _setup.UpdateCategoryAsync(expenseCategoryId, request, ct));
|
||||
|
||||
[HttpPost("expense-categories/{expenseCategoryId:int}/titles")]
|
||||
[Authorize(Policy = SetupPolicy)]
|
||||
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseTitleDto>> CreateTitle(int expenseCategoryId, [FromBody] CreateExpenseTitleRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _setup.CreateTitleAsync(expenseCategoryId, request, ct);
|
||||
return Created($"/api/v1/expense-titles/{dto.ExpenseTitleId}", dto);
|
||||
}
|
||||
|
||||
[HttpPut("expense-titles/{expenseTitleId:int}")]
|
||||
[Authorize(Policy = SetupPolicy)]
|
||||
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseTitleDto>> UpdateTitle(int expenseTitleId, [FromBody] UpdateExpenseTitleRequest request, CancellationToken ct)
|
||||
=> Ok(await _setup.UpdateTitleAsync(expenseTitleId, request, ct));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Expenses;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Business expense bills — draft, post (GL accrual), pay, cancel (docs/EXPENSE_MODULE_PLAN.md §6).</summary>
|
||||
[Route("api/v1/expenses")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.expenses")]
|
||||
public sealed class ExpensesController : ApiControllerBase
|
||||
{
|
||||
private readonly IExpenseService _expenses;
|
||||
private readonly IExpensePaymentService _payments;
|
||||
|
||||
public ExpensesController(IExpenseService expenses, IExpensePaymentService payments)
|
||||
{
|
||||
_expenses = expenses;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ExpenseSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ExpenseSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] ExpenseStatus? status, [FromQuery] ExpensePaymentStatus? paymentStatus,
|
||||
[FromQuery] int? categoryId, [FromQuery] int? titleId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] bool overdueOnly, CancellationToken ct)
|
||||
=> Ok(await _expenses.ListAsync(query, new ExpenseListFilter(status, paymentStatus, categoryId, titleId, from, to, overdueOnly), ct));
|
||||
|
||||
/// <summary>Totals by category → title for Posted expenses dated in [from, to] (default: this month), plus unpaid/overdue position.</summary>
|
||||
[HttpGet("summary")]
|
||||
[ProducesResponseType(typeof(ExpenseSummaryReportDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ExpenseSummaryReportDto>> Summary([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _expenses.GetSummaryAsync(from, to, ct));
|
||||
|
||||
[HttpGet("{expenseId:int}")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ExpenseDto>> GetById(int expenseId, CancellationToken ct)
|
||||
{
|
||||
var result = await _expenses.GetAsync(expenseId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ExpenseDto>> Create([FromBody] CreateExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _expenses.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/expenses/{result.Value.ExpenseId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{expenseId:int}")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ExpenseDto>> Update(int expenseId, [FromBody] UpdateExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _expenses.UpdateAsync(expenseId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Draft only.</summary>
|
||||
[HttpDelete("{expenseId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int expenseId, CancellationToken ct)
|
||||
{
|
||||
await _expenses.DeleteAsync(expenseId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Accrue the expense to GL (Debit expense account / Credit Accrued Expenses).</summary>
|
||||
[HttpPost("{expenseId:int}/post")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseDto>> Post(int expenseId, CancellationToken ct)
|
||||
=> Ok(await _expenses.PostAsync(expenseId, ct));
|
||||
|
||||
/// <summary>Post a Draft and pay it, in full or partially, in one transaction; any remainder stays as the balance.</summary>
|
||||
[HttpPost("{expenseId:int}/post-and-pay")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseDto>> PostAndPay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
|
||||
=> Ok(await _payments.PostAndPayAsync(expenseId, request, ct));
|
||||
|
||||
/// <summary>Draft, or Posted with no payments (posts the reversing GL entry).</summary>
|
||||
[HttpPost("{expenseId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseDto>> Cancel(int expenseId, [FromBody] CancelExpenseRequest request, CancellationToken ct)
|
||||
=> Ok(await _expenses.CancelAsync(expenseId, request, ct));
|
||||
|
||||
/// <summary>Pay against the balance, in full or in installments; one GL entry per submission.</summary>
|
||||
[HttpPost("{expenseId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> Pay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var rows = await _payments.PayAsync(expenseId, request, ct);
|
||||
return Created($"/api/v1/expenses/{expenseId}/payments", rows);
|
||||
}
|
||||
|
||||
/// <summary>Payment history, newest first.</summary>
|
||||
[HttpGet("{expenseId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> ListPayments(int expenseId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(expenseId, ct));
|
||||
}
|
||||
@@ -57,6 +57,13 @@ public sealed class ItemsController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <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)]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Approvals;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Products;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6.4). Requesting needs Create on
|
||||
/// <c>products.price-adjustments</c>, deciding needs Approve; an item's applied price history is
|
||||
/// readable by anyone who can see Products.
|
||||
/// </summary>
|
||||
[Route("api/v1/price-adjustments")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class PriceAdjustmentsController : ApiControllerBase
|
||||
{
|
||||
private const string Screen = NavPolicies.Prefix + "products.price-adjustments";
|
||||
private const string CreatePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.CreateAction;
|
||||
private const string ApprovePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
|
||||
|
||||
private readonly IPriceAdjustmentService _prices;
|
||||
|
||||
public PriceAdjustmentsController(IPriceAdjustmentService prices) => _prices = prices;
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = Screen)]
|
||||
[ProducesResponseType(typeof(PagedResponse<PriceAdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PriceAdjustmentSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] PriceAdjustmentStatus? status, [FromQuery] int? itemId, CancellationToken ct)
|
||||
=> Ok(await _prices.ListAsync(query, status, itemId, ct));
|
||||
|
||||
[HttpGet("{priceAdjustmentId:int}")]
|
||||
[Authorize(Policy = Screen)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> GetById(int priceAdjustmentId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _prices.GetAsync(priceAdjustmentId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Request new selling prices for one or more items.</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = CreatePolicy)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Create([FromBody] CreatePriceAdjustmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _prices.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/price-adjustments/{dto.PriceAdjustmentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Give the next approval; the final one applies the prices.</summary>
|
||||
[HttpPost("{priceAdjustmentId:int}/approve")]
|
||||
[Authorize(Policy = ApprovePolicy)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Approve(int priceAdjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
|
||||
=> Ok(await _prices.ApproveAsync(priceAdjustmentId, request?.Comment, ct));
|
||||
|
||||
[HttpPost("{priceAdjustmentId:int}/reject")]
|
||||
[Authorize(Policy = ApprovePolicy)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Reject(int priceAdjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
|
||||
=> Ok(await _prices.RejectAsync(priceAdjustmentId, request.Reason, ct));
|
||||
|
||||
/// <summary>The requester withdraws a pending request.</summary>
|
||||
[HttpPost("{priceAdjustmentId:int}/cancel")]
|
||||
[Authorize(Policy = Screen)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Cancel(int priceAdjustmentId, CancellationToken ct)
|
||||
=> Ok(await _prices.CancelAsync(priceAdjustmentId, ct));
|
||||
|
||||
/// <summary>Every applied price change for an item, newest first, with who approved it.</summary>
|
||||
[HttpGet("/api/v1/items/{itemId:int}/price-history")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ItemPriceHistoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<ItemPriceHistoryDto>>> ItemHistory(int itemId, CancellationToken ct)
|
||||
=> Ok(await _prices.ItemHistoryAsync(itemId, ct));
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Approvals;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,23 +9,30 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||
/// <summary>
|
||||
/// Stock-adjustment endpoints (docs/11 §5.5). Creating submits a request; it moves stock once
|
||||
/// approved (docs/DUAL_APPROVAL_PLAN.md §5.4). Approving or rejecting needs the Approve action on
|
||||
/// <c>stock.adjustments</c>; the two-person rules are enforced by the service.
|
||||
/// </summary>
|
||||
[Route("api/v1/stock-adjustments")]
|
||||
[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 posted adjustments, newest first.</summary>
|
||||
/// <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, CancellationToken ct)
|
||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
|
||||
[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 and the ledger entries it posted.</summary>
|
||||
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
|
||||
[HttpGet("{adjustmentId:int}")]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -33,7 +42,7 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||
/// <summary>Submit an adjustment (mandatory reason code). Posts at once only when approvals are off.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
@@ -44,4 +53,29 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user