Merge pull request 'Dev' (#7) from Dev into production

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-07-15 10:08:07 +00:00
232 changed files with 24584 additions and 1493 deletions
@@ -1,5 +1,7 @@
using ERPCore.Common.Http; using ERPCore.Common.Http;
using ERPCore.Infra.Auth;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers; namespace ERPCore.Controllers;
@@ -8,10 +10,12 @@ namespace ERPCore.Controllers;
/// Base for the v1 API controllers. Centralises ETag / If-Match handling /// Base for the v1 API controllers. Centralises ETag / If-Match handling
/// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform. /// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform.
/// Each controller declares its own explicit lowercase <c>[Route]</c> to match /// Each controller declares its own explicit lowercase <c>[Route]</c> to match
/// the API contract paths (docs/11 §1.1). /// 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> /// </summary>
[ApiController] [ApiController]
[Produces("application/json")] [Produces("application/json")]
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
public abstract class ApiControllerBase : ControllerBase public abstract class ApiControllerBase : ControllerBase
{ {
/// <summary>Parse a mandatory <c>If-Match</c> header, or 428 if absent/malformed.</summary> /// <summary>Parse a mandatory <c>If-Match</c> header, or 428 if absent/malformed.</summary>
@@ -0,0 +1,26 @@
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));
}
@@ -0,0 +1,52 @@
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")]
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
public GrnsController(IGrnService grns) => _grns = grns;
[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));
}
+11 -11
View File
@@ -20,16 +20,16 @@ public sealed class ItemsController : ApiControllerBase
public async Task<ActionResult<PagedResponse<ItemListItemDto>>> List( public async Task<ActionResult<PagedResponse<ItemListItemDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PageQuery query,
[FromQuery] EntityStatus? status, [FromQuery] EntityStatus? status,
[FromQuery] long? categoryId, [FromQuery] int? categoryId,
[FromQuery] TrackingMode? trackingMode, [FromQuery] TrackingMode? trackingMode,
CancellationToken ct) CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct)); => Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary> /// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:long}")] [HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemDetailDto>> GetById(long itemId, CancellationToken ct) public async Task<ActionResult<ItemDetailDto>> GetById(int itemId, CancellationToken ct)
{ {
var result = await _items.GetAsync(itemId, ct); var result = await _items.GetAsync(itemId, ct);
if (result is null) return NotFound(); if (result is null) return NotFound();
@@ -51,11 +51,11 @@ public sealed class ItemsController : ApiControllerBase
} }
/// <summary>Full update; requires <c>If-Match</c> (412 on stale ETag).</summary> /// <summary>Full update; requires <c>If-Match</c> (412 on stale ETag).</summary>
[HttpPut("{itemId:long}")] [HttpPut("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ItemDetailDto>> Update(long itemId, [FromBody] UpdateItemRequest request, CancellationToken ct) public async Task<ActionResult<ItemDetailDto>> Update(int itemId, [FromBody] UpdateItemRequest request, CancellationToken ct)
{ {
var expected = RequireIfMatch(); var expected = RequireIfMatch();
var result = await _items.UpdateAsync(itemId, request, expected, ct); var result = await _items.UpdateAsync(itemId, request, expected, ct);
@@ -64,26 +64,26 @@ public sealed class ItemsController : ApiControllerBase
} }
/// <summary>Activate / deactivate the item (FR-MD-08 — deactivate, not delete).</summary> /// <summary>Activate / deactivate the item (FR-MD-08 — deactivate, not delete).</summary>
[HttpPatch("{itemId:long}/status")] [HttpPatch("{itemId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(long itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct) public async Task<IActionResult> SetStatus(int itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct)
{ {
await _items.SetStatusAsync(itemId, request.Status, ct); await _items.SetStatusAsync(itemId, request.Status, ct);
return NoContent(); return NoContent();
} }
/// <summary>Replace the item's per-warehouse reorder settings (FR-MD-05).</summary> /// <summary>Replace the item's per-warehouse reorder settings (FR-MD-05).</summary>
[HttpPut("{itemId:long}/reorder")] [HttpPut("{itemId:int}/reorder")]
[ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(long itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct) public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct)); => Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary> /// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
[HttpPut("{itemId:long}/uom-conversions")] [HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(long itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct) public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct)); => Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
} }
@@ -0,0 +1,24 @@
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")]
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));
}
@@ -0,0 +1,74 @@
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")]
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 — auto-approved on creation in Phase 1 (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>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
[HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
=> Ok(await _pos.ApproveAsync(poId, 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));
}
@@ -0,0 +1,26 @@
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")]
public sealed class PurchaseReturnsController : ApiControllerBase
{
private readonly IPurchaseReturnService _returns;
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
/// <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);
}
}
@@ -0,0 +1,31 @@
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);
}
}
@@ -0,0 +1,44 @@
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")]
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, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, 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));
}
@@ -0,0 +1,49 @@
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")]
public sealed class RfqsController : ApiControllerBase
{
private readonly IRfqService _rfqs;
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
[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));
}
@@ -0,0 +1,26 @@
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
[Route("api/v1/stock-adjustments")]
public sealed class StockAdjustmentsController : ApiControllerBase
{
private readonly IAdjustmentService _adjustments;
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</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);
}
}
@@ -0,0 +1,56 @@
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.15.3, §5.7).</summary>
[Route("api/v1/stock")]
public sealed class StockController : ApiControllerBase
{
private readonly IStockService _stock;
private readonly IReorderService _reorder;
public StockController(IStockService stock, IReorderService reorder)
{
_stock = stock;
_reorder = reorder;
}
[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));
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, 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] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, 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);
}
}
@@ -0,0 +1,49 @@
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")]
public sealed class StockCountsController : ApiControllerBase
{
private readonly ICountService _counts;
public StockCountsController(ICountService counts) => _counts = counts;
[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));
}
@@ -0,0 +1,49 @@
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")]
public sealed class StockTransfersController : ApiControllerBase
{
private readonly ITransferService _transfers;
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
[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));
}
@@ -20,10 +20,10 @@ public sealed class VendorsController : ApiControllerBase
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _vendors.ListAsync(query, status, ct)); => Ok(await _vendors.ListAsync(query, status, ct));
[HttpGet("{vendorId:long}")] [HttpGet("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VendorDto>> GetById(long vendorId, CancellationToken ct) public async Task<ActionResult<VendorDto>> GetById(int vendorId, CancellationToken ct)
{ {
var result = await _vendors.GetAsync(vendorId, ct); var result = await _vendors.GetAsync(vendorId, ct);
if (result is null) return NotFound(); if (result is null) return NotFound();
@@ -42,11 +42,11 @@ public sealed class VendorsController : ApiControllerBase
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value); return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
} }
[HttpPut("{vendorId:long}")] [HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<VendorDto>> Update(long vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct) public async Task<ActionResult<VendorDto>> Update(int vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct)
{ {
var expected = RequireIfMatch(); var expected = RequireIfMatch();
var result = await _vendors.UpdateAsync(vendorId, request, expected, ct); var result = await _vendors.UpdateAsync(vendorId, request, expected, ct);
@@ -54,10 +54,10 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[HttpPatch("{vendorId:long}/status")] [HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(long vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct) public async Task<IActionResult> SetStatus(int vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
{ {
await _vendors.SetStatusAsync(vendorId, request.Status, ct); await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent(); return NoContent();
@@ -18,10 +18,10 @@ public sealed class WarehousesController : ApiControllerBase
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct) public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct)); => Ok(await _warehouses.ListAsync(query, ct));
[HttpGet("{warehouseId:long}")] [HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WarehouseDto>> GetById(long warehouseId, CancellationToken ct) public async Task<ActionResult<WarehouseDto>> GetById(int warehouseId, CancellationToken ct)
{ {
var dto = await _warehouses.GetAsync(warehouseId, ct); var dto = await _warehouses.GetAsync(warehouseId, ct);
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
@@ -36,17 +36,17 @@ public sealed class WarehousesController : ApiControllerBase
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto); return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
} }
[HttpGet("{warehouseId:long}/bins")] [HttpGet("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<BinDto>>> ListBins(long warehouseId, CancellationToken ct) public async Task<ActionResult<IReadOnlyList<BinDto>>> ListBins(int warehouseId, CancellationToken ct)
=> Ok(await _warehouses.ListBinsAsync(warehouseId, ct)); => Ok(await _warehouses.ListBinsAsync(warehouseId, ct));
[HttpPost("{warehouseId:long}/bins")] [HttpPost("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BinDto>> CreateBin(long warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct) public async Task<ActionResult<BinDto>> CreateBin(int warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct)
{ {
var dto = await _warehouses.CreateBinAsync(warehouseId, request, ct); var dto = await _warehouses.CreateBinAsync(warehouseId, request, ct);
return Created($"/api/v1/warehouses/{warehouseId}/bins/{dto.BinId}", dto); return Created($"/api/v1/warehouses/{warehouseId}/bins/{dto.BinId}", dto);
+17
View File
@@ -0,0 +1,17 @@
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 Transfer = "TRF";
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
}
@@ -0,0 +1,23 @@
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; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Batch/lot for a batch-tracked item (FR-GRN-04, FR-WH-03). Expiry drives FEFO
/// picking of perishables. Model: docs/10 Part C.4.
/// </summary>
public class Batch
{
public int BatchId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string BatchNo { get; set; } = string.Empty;
public DateOnly? ExpiryDate { get; set; }
}
+2 -2
View File
@@ -6,9 +6,9 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class Bin public class Bin
{ {
public long BinId { get; set; } public int BinId { get; set; }
public long WarehouseId { get; set; } public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
+2 -2
View File
@@ -6,10 +6,10 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class Category public class Category
{ {
public long CategoryId { get; set; } public int CategoryId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public long? ParentId { get; set; } public int? ParentId { get; set; }
public Category? Parent { get; set; } public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>(); public ICollection<Category> Children { get; set; } = new List<Category>();
} }
+37
View File
@@ -0,0 +1,37 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Goods Receipt Note header (FR-GRN-01/02). Raised against a PO or direct
/// (<see cref="PoId"/> null). On confirm each line creates a FIFO layer and posts
/// an inbound ledger entry. Mutable aggregate with an <see cref="RowVersion"/>
/// concurrency token (docs/10 C.10). Model: docs/10 Part C.3.
/// </summary>
public class Grn
{
public int GrnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int? PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? PostedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
}
@@ -0,0 +1,37 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
/// </summary>
public class GrnLine
{
public int GrnLineId { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public int? PoLineId { get; set; }
public PoLine? PoLine { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
public decimal UnitCost { get; set; }
public decimal ReceivedValue { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
}
+4 -4
View File
@@ -9,18 +9,18 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class Item public class Item
{ {
public long ItemId { get; set; } public int ItemId { get; set; }
public string Sku { get; set; } = string.Empty; public string Sku { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
public long CategoryId { get; set; } public int CategoryId { get; set; }
public Category? Category { get; set; } public Category? Category { get; set; }
public long BaseUomId { get; set; } public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; } public Uom? BaseUom { get; set; }
public long? DefaultVendorId { get; set; } public int? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; } public Vendor? DefaultVendor { get; set; }
public ItemType ItemType { get; set; } public ItemType ItemType { get; set; }
@@ -7,12 +7,12 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class ItemReorder public class ItemReorder
{ {
public long ReorderId { get; set; } public int ReorderId { get; set; }
public long ItemId { get; set; } public int ItemId { get; set; }
public Item? Item { get; set; } public Item? Item { get; set; }
public long WarehouseId { get; set; } public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
public decimal ReorderPoint { get; set; } public decimal ReorderPoint { get; set; }
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// GL-ready journal entry emitted per stock movement (FR-STK-13) — data only, no
/// posting in Phase 1 (the Accounting module consumes these later). One row per
/// ledger entry, referencing the same source document polymorphically. Account
/// codes are Phase-1 placeholders until a chart of accounts exists.
/// Model: docs/10 Part C.7.
/// </summary>
public class JournalEntryStub
{
public int JournalId { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public string DebitAccount { get; set; } = string.Empty;
public string CreditAccount { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-document-type, per-year running counter behind human document numbers
/// (FR-X-03): <c>PR-2026-00001</c>, <c>PO-2026-00042</c>, … Numbers are issued
/// inside the document's transaction so they are unique and gap-controlled.
/// Model: docs/10 Part C.7.
/// </summary>
public class NumberSequence
{
public int SequenceId { get; set; }
public string DocType { get; set; } = string.Empty;
public int Year { get; set; }
public int LastNumber { get; set; }
}
+28
View File
@@ -0,0 +1,28 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Purchase-order line (FR-PROC-03). <see cref="Tax"/> is the line tax rate
/// (e.g. 0.18); <see cref="QtyReceived"/> accrues as GRNs confirm (FR-PROC-07).
/// Model: docs/10 Part C.2.
/// </summary>
public class PoLine
{
public int PoLineId { get; set; }
public int PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }
public decimal Tax { get; set; }
public decimal QtyReceived { get; set; }
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a
/// <see cref="RowVersion"/> ETag token; editable while open (FR-PROC-05).
/// Phase 1 auto-approves on creation; <see cref="ApprovalRequired"/> is retained
/// for the future approval workflow. Totals are computed server-side from lines
/// (not stored). Model: docs/10 Part C.2.
/// </summary>
public class PurchaseOrder
{
public int PoId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public int? RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
public bool ApprovalRequired { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
public ICollection<PoLine> Lines { get; set; } = new List<PoLine>();
}
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Purchase return header (FR-PROC-08) — returns received goods to a vendor,
/// generating an outbound stock movement. Auto-posts with a mandatory reason code.
/// Model: docs/10 Part C.2.
/// </summary>
public class PurchaseReturn
{
public int ReturnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Purchase-return line (FR-PROC-08) referencing the original GRN line for
/// traceability. <see cref="Qty"/> is in base UOM. Model: docs/10 Part C.2.
/// </summary>
public class PurchaseReturnLine
{
public int ReturnLineId { get; set; }
public int ReturnId { get; set; }
public PurchaseReturn? Return { get; set; }
public int? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
}
@@ -0,0 +1,15 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Configurable reason code for adjustments, returns and count variances
/// (FR-X-04). Model: docs/10 Part C.7.
/// </summary>
public class ReasonCode
{
public int ReasonCodeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ReasonContext Context { get; set; }
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Purchase requisition header (FR-PROC-01). <see cref="RequestedBy"/> is the audit
/// actor from the token (never the body). Model: docs/10 Part C.2.
/// </summary>
public class Requisition
{
public int RequisitionId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int RequestedBy { get; set; }
public User? Requester { get; set; }
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
public DateTime CreatedAt { get; set; }
public ICollection<RequisitionLine> Lines { get; set; } = new List<RequisitionLine>();
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>Requisition line (FR-PROC-01). Model: docs/10 Part C.2.</summary>
public class RequisitionLine
{
public int ReqLineId { get; set; }
public int RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
public DateOnly? RequiredBy { get; set; }
}
+22
View File
@@ -0,0 +1,22 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Request for Quotation header (FR-PROC-02) raised from a requisition. Vendor
/// quotations attach for comparison. Model: docs/10 Part C.2.
/// </summary>
public class Rfq
{
public int RfqId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public RfqStatus Status { get; set; } = RfqStatus.Open;
public DateTime CreatedAt { get; set; }
public ICollection<RfqLine> Lines { get; set; } = new List<RfqLine>();
public ICollection<VendorQuotation> Quotations { get; set; } = new List<VendorQuotation>();
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Entities;
/// <summary>RFQ line — an item + quantity being quoted (FR-PROC-02). Model: docs/10 Part C.2.</summary>
public class RfqLine
{
public int RfqLineId { get; set; }
public int RfqId { get; set; }
public Rfq? Rfq { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
/// Model: docs/10 Part C.4.
/// </summary>
public class Serial
{
public int SerialId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string SerialNo { get; set; } = string.Empty;
public string Status { get; set; } = "InStock";
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Stock adjustment header (FR-STK-07) — the highest-risk feature in the phase
/// (02-SECURITY C.5). Auto-posts in Phase 1 with a mandatory reason code and user
/// stamp. Mutable aggregate with an <see cref="RowVersion"/> token (docs/10 C.10).
/// Model: docs/10 Part C.6.
/// </summary>
public class StockAdjustment
{
public int AdjustmentId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
}
@@ -0,0 +1,23 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Adjustment line (FR-STK-07). <see cref="QtyDelta"/> is a signed base-UOM
/// quantity: negative consumes FIFO layers, positive creates a layer at last cost.
/// Model: docs/10 Part C.6.
/// </summary>
public class StockAdjustmentLine
{
public int AdjLineId { get; set; }
public int AdjustmentId { get; set; }
public StockAdjustment? Adjustment { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int? BinId { get; set; }
public int? BatchId { get; set; }
public int? SerialId { get; set; }
public decimal QtyDelta { get; set; }
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Cycle/full physical count header (FR-STK-08). System quantities are snapshotted
/// at creation and are immutable once opened (02-SECURITY C.7); posting emits a
/// variance adjustment. Mutable aggregate with an <see cref="RowVersion"/> token.
/// Model: docs/10 Part C.6.
/// </summary>
public class StockCount
{
public int CountId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<StockCountLine> Lines { get; set; } = new List<StockCountLine>();
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Count line (FR-STK-08). <see cref="SystemQty"/> is the immutable snapshot;
/// <see cref="Variance"/> = counted system (in base UOM). Model: docs/10 Part C.6.
/// </summary>
public class StockCountLine
{
public int CountLineId { get; set; }
public int CountId { get; set; }
public StockCount? Count { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int? BinId { get; set; }
public decimal SystemQty { get; set; }
public decimal? CountedQty { get; set; }
public decimal? Variance { get; set; }
}
@@ -0,0 +1,33 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// FIFO cost layer — a quantity received at a specific unit cost, consumed
/// oldest-first (FR-STK-02). Keyed per item **per warehouse**; quantities and
/// <see cref="UnitCost"/> are in the item's base UOM. Answers valuation
/// ("what's on hand and at what cost"). Model: docs/10 Part C.5.
/// </summary>
public class StockLayer
{
public int LayerId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
/// <summary>Originating GRN line — carries the inspection hold status for this stock.</summary>
public int? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public decimal QtyReceived { get; set; }
public decimal QtyRemaining { get; set; }
public decimal UnitCost { get; set; }
public DateTime ReceiptDate { get; set; }
}
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Immutable, append-only stock ledger (FR-STK-01, FR-X-05). One row per costed
/// movement; answers history ("what moved, when, by whom"). The originating
/// document is referenced polymorphically via
/// <see cref="SourceDocType"/>/<see cref="SourceDocId"/> (no hard FK per type) so
/// new transaction types write here without a schema change. Model: docs/10 Part C.5.
/// </summary>
public class StockLedger
{
public int LedgerId { get; set; }
public int ItemId { get; set; }
public int WarehouseId { get; set; }
public int? BinId { get; set; }
public int? BatchId { get; set; }
public int? SerialId { get; set; }
public int UserId { get; set; }
public Direction Direction { get; set; }
public decimal QtyBase { get; set; }
public decimal UnitCost { get; set; }
public decimal Value { get; set; }
public decimal RunningBalance { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Inter-warehouse stock transfer header (FR-STK-05/06). Dispatch consumes source
/// FIFO layers into in-transit; receive creates the destination layer at the
/// inherited cost (cost-preserving). Mutable aggregate with an
/// <see cref="RowVersion"/> token (docs/10 C.10). Model: docs/10 Part C.6.
/// </summary>
public class StockTransfer
{
public int TransferId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int SrcWarehouseId { get; set; }
public Warehouse? SrcWarehouse { get; set; }
public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<StockTransferLine> Lines { get; set; } = new List<StockTransferLine>();
}
@@ -0,0 +1,35 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Transfer line (FR-STK-05/06). <see cref="Qty"/> is in base UOM.
/// <para>
/// Deviation note: <see cref="UnitCost"/> and <see cref="QtyReceived"/> extend
/// docs/10 Part C.6's <c>STOCK_TRANSFER_LINE</c> to make the transfer
/// cost-preserving: at dispatch the value-weighted cost of the consumed source
/// layers is stored here, and receive recreates the destination layer at that cost
/// (supports partial receive via <see cref="QtyReceived"/>).
/// </para>
/// </summary>
public class StockTransferLine
{
public int TransferLineId { get; set; }
public int TransferId { get; set; }
public StockTransfer? Transfer { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int? SrcBinId { get; set; }
public int? DestBinId { get; set; }
public int? BatchId { get; set; }
public int? SerialId { get; set; }
public decimal Qty { get; set; }
/// <summary>Value-weighted unit cost of the consumed source layers (set at dispatch).</summary>
public decimal? UnitCost { get; set; }
/// <summary>Quantity already received at the destination (partial-receive support).</summary>
public decimal QtyReceived { get; set; }
}
+1 -1
View File
@@ -6,6 +6,6 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class Uom public class Uom
{ {
public long UomId { get; set; } public int UomId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
} }
@@ -7,15 +7,15 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class UomConversion public class UomConversion
{ {
public long ConversionId { get; set; } public int ConversionId { get; set; }
public long ItemId { get; set; } public int ItemId { get; set; }
public Item? Item { get; set; } public Item? Item { get; set; }
public long FromUomId { get; set; } public int FromUomId { get; set; }
public Uom? FromUom { get; set; } public Uom? FromUom { get; set; }
public long ToUomId { get; set; } public int ToUomId { get; set; }
public Uom? ToUom { get; set; } public Uom? ToUom { get; set; }
public decimal Factor { get; set; } public decimal Factor { get; set; }
+25
View File
@@ -0,0 +1,25 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Application user (FR-X-01) — a **local shadow/projection** of an AuthHex identity.
/// The local <see cref="UserId"/> (int) is what every `createdBy`/`requestedBy`/
/// audit/ledger FK references; <see cref="AuthUserId"/> maps it to the AuthHex
/// <c>UserId</c> (GUID) and is JIT-provisioned on first authenticated request
/// (docs/10 A.4/C.7). A seeded <c>system</c> user (id 1, null AuthUserId) is the
/// fallback actor for unauthenticated/system operations. Model: docs/10 Part C.7.
/// </summary>
public class User
{
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
public const int SystemUserId = 1;
public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
public Guid? AuthUserId { get; set; }
}
+1 -1
View File
@@ -9,7 +9,7 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class Vendor public class Vendor
{ {
public long VendorId { get; set; } public int VendorId { get; set; }
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Terms { get; set; } public string? Terms { get; set; }
@@ -0,0 +1,26 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A vendor's quotation against an RFQ (FR-PROC-02). Per-item pricing lives in
/// <see cref="Lines"/>.
/// <para>
/// Deviation note: docs/10 Part C.2 models <c>VENDOR_QUOTATION</c> with scalar
/// <c>unit_price</c>/<c>lead_days</c> and no item reference, which cannot represent
/// the per-line pricing the API contract requires (docs/11 §3.2). This header +
/// <see cref="VendorQuotationLine"/> split follows the authoritative API shape.
/// </para>
/// </summary>
public class VendorQuotation
{
public int QuotationId { get; set; }
public int RfqId { get; set; }
public Rfq? Rfq { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<VendorQuotationLine> Lines { get; set; } = new List<VendorQuotationLine>();
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>Per-item quoted price and lead time within a <see cref="VendorQuotation"/> (docs/11 §3.2).</summary>
public class VendorQuotationLine
{
public int QuotationLineId { get; set; }
public int QuotationId { get; set; }
public VendorQuotation? Quotation { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal UnitPrice { get; set; }
public int LeadDays { get; set; }
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ERPCore.Domain.Entities;
/// </summary> /// </summary>
public class Warehouse public class Warehouse
{ {
public long WarehouseId { get; set; } public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
/// <see cref="PendingApproval"/> is reserved for the future threshold-approval
/// workflow (FR-STK-07). Stored as a string.
/// </summary>
public enum AdjustmentStatus
{
Draft,
PendingApproval,
Posted
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Kind of mutation recorded in the audit trail (FR-X-02). Stored as a string.</summary>
public enum AuditAction
{
Create,
Update,
Delete
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Stock-count lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
public enum CountStatus
{
Draft,
Counted,
Posted
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Physical-count scope (docs/11 §8; FR-STK-08). Stored as a string.</summary>
public enum CountType
{
Cycle,
Full
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Stock-ledger movement direction (docs/11 §8). Stored as a string.</summary>
public enum Direction
{
In,
Out
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Goods-receipt-note lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
public enum GrnStatus
{
Draft,
Confirmed,
Closed
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Inspection-hold state of received stock (docs/11 §8; FR-GRN-05). <see cref="OnHold"/>
/// stock is on-hand but not issuable until released (FR-WH-07). Stored as a string.
/// </summary>
public enum HoldStatus
{
Available,
OnHold,
Rejected
}
@@ -0,0 +1,17 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Phase 1 auto-approves on
/// creation, so <see cref="PendingApproval"/> is reserved (not entered) until the
/// approval workflow is enabled (FR-PROC-04). Stored as a string.
/// </summary>
public enum PurchaseOrderStatus
{
Draft,
PendingApproval,
Approved,
PartiallyReceived,
FullyReceived,
Closed,
Cancelled
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Where a reason code applies (FR-X-04; docs/10 §B.8.3). Stored as a string.</summary>
public enum ReasonContext
{
Adjustment,
Return,
Count
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Purchase-requisition lifecycle (docs/11 §3.1; docs/10 §B.8.1). Stored as a string.</summary>
public enum RequisitionStatus
{
Draft,
Submitted
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Purchase-return lifecycle (docs/11 §3.4). Auto-posts in Phase 1. Stored as a string.</summary>
public enum ReturnStatus
{
Draft,
Posted
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>RFQ lifecycle (docs/11 §3.2). Stored as a string.</summary>
public enum RfqStatus
{
Open,
Closed
}
@@ -0,0 +1,10 @@
namespace ERPCore.Domain.Enums;
/// <summary>Stock-transfer lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
public enum TransferStatus
{
Draft,
InTransit,
Received,
Closed
}
+12
View File
@@ -0,0 +1,12 @@
using System.Text.Json;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Audit;
/// <summary>An audit-trail entry (FR-X-02). <c>ChangeSet</c> is the stored JSON, inlined.</summary>
public sealed record AuditLogDto(
int AuditId, int UserId, string EntityType, int EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt);
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
public sealed record JournalEntryStubDto(
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
@@ -3,13 +3,13 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Categories; namespace ERPCore.Dtos.Categories;
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary> /// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public sealed record CategoryDto(long CategoryId, string Name, long? ParentId); public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary> /// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
public sealed record CategoryTreeDto(long CategoryId, string Name, long? ParentId, IReadOnlyList<CategoryTreeDto> Children); public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
public sealed class CreateCategoryRequest public sealed class CreateCategoryRequest
{ {
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public long? ParentId { get; set; } public int? ParentId { get; set; }
} }
+63
View File
@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
int GrnId, GrnStatus Status, DateTime PostedAt,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
// Requests ----------------------------------------------------------------------
public sealed class BatchInput
{
[Required, StringLength(50)] public string BatchNo { get; set; } = string.Empty;
public DateOnly? ExpiryDate { get; set; }
}
public sealed class CreateGrnLineInput
{
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
public int? PoLineId { get; set; }
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
}
public sealed class CreateGrnRequest
{
/// <summary>PO to receive against; null for a direct/emergency receipt (FR-GRN-02).</summary>
public int? PoId { get; set; }
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
public sealed class ReleaseLineRequest
{
/// <summary><c>Release</c> makes the stock available; <c>Reject</c> removes it from on-hand.</summary>
[Required, RegularExpression("Release|Reject")]
public string Action { get; set; } = "Release";
}
+16 -16
View File
@@ -7,25 +7,25 @@ namespace ERPCore.Dtos.Items;
/// <summary>Row shape for <c>GET /items</c>.</summary> /// <summary>Row shape for <c>GET /items</c>.</summary>
public sealed record ItemListItemDto( public sealed record ItemListItemDto(
long ItemId, string Sku, string Name, long CategoryId, long BaseUomId, int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status); string? TaxClass, EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary> /// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(long WarehouseId, decimal ReorderPoint, decimal ReorderQty); public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary> /// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
public sealed record ItemDetailDto( public sealed record ItemDetailDto(
long ItemId, string Sku, string Name, string? Description, long CategoryId, int ItemId, string Sku, string Name, string? Description, int CategoryId,
long BaseUomId, long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder, string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
DateTime CreatedAt, DateTime? UpdatedAt); DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary> /// <summary>UOM conversion row (docs/11 §2.2).</summary>
public sealed record UomConversionDto(long ConversionId, long FromUom, long ToUom, decimal Factor); public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary> /// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
public sealed record ItemUomConversionsDto(long ItemId, long BaseUomId, IReadOnlyList<UomConversionDto> Conversions); public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary> /// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings); public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
@@ -38,9 +38,9 @@ public sealed class CreateItemRequest
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty; [Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; } [StringLength(1000)] public string? Description { get; set; }
[Required] public long CategoryId { get; set; } [Required] public int CategoryId { get; set; }
[Required] public long BaseUomId { get; set; } [Required] public int BaseUomId { get; set; }
public long? DefaultVendorId { get; set; } public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; } [StringLength(20)] public string? TaxClass { get; set; }
@@ -51,9 +51,9 @@ public sealed class UpdateItemRequest
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty; [Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; } [StringLength(1000)] public string? Description { get; set; }
[Required] public long CategoryId { get; set; } [Required] public int CategoryId { get; set; }
[Required] public long BaseUomId { get; set; } [Required] public int BaseUomId { get; set; }
public long? DefaultVendorId { get; set; } public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; } [StringLength(20)] public string? TaxClass { get; set; }
@@ -66,7 +66,7 @@ public sealed class UpdateItemStatusRequest
public sealed class ReorderSettingInput public sealed class ReorderSettingInput
{ {
[Required] public long WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderPoint { get; set; } [Range(0, double.MaxValue)] public decimal ReorderPoint { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderQty { get; set; } [Range(0, double.MaxValue)] public decimal ReorderQty { get; set; }
} }
@@ -78,8 +78,8 @@ public sealed class UpdateReorderRequest
public sealed class UomConversionInput public sealed class UomConversionInput
{ {
[Required] public long FromUom { get; set; } [Required] public int FromUom { get; set; }
[Required] public long ToUom { get; set; } [Required] public int ToUom { get; set; }
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; } [Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
} }
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
public sealed record PoLineDto(
int PoLineId, int ItemId, int UomId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
public sealed record PurchaseOrderDto(
int PoId, string DocNo, int VendorId, int? RequisitionId, PurchaseOrderStatus Status,
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
public sealed record PurchaseOrderSummaryDto(
int PoId, string DocNo, int VendorId, PurchaseOrderStatus Status,
bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals);
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
public sealed class CreatePoLineInput
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
[Range(0, 1)] public decimal Tax { get; set; }
}
public sealed class CreatePurchaseOrderRequest
{
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
}
public sealed class UpdatePurchaseOrderRequest
{
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
}
public sealed class CancelPurchaseOrderRequest
{
[StringLength(500)] public string? Reason { get; set; }
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.4) ------------------------------------------------------
public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int ItemId, decimal Qty);
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreatePurchaseReturnLineInput
{
/// <summary>Original GRN line, for traceability against the receipt.</summary>
public int? GrnLineId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreatePurchaseReturnRequest
{
[Required] public int VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreatePurchaseReturnLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.1) ------------------------------------------------------
public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy);
public sealed record RequisitionDto(
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy,
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
public sealed record RequisitionSummaryDto(
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
public sealed class CreateRequisitionLineInput
{
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
public DateOnly? RequiredBy { get; set; }
}
public sealed class CreateRequisitionRequest
{
[Required, MinLength(1)] public List<CreateRequisitionLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.2) ------------------------------------------------------
public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
public sealed record VendorQuotationDto(
int QuotationId, int RfqId, int VendorId, IReadOnlyList<QuotationLineDto> Lines);
/// <summary>Per-item, per-vendor price matrix for <c>GET /rfqs/{id}/comparison</c>.</summary>
public sealed record RfqComparisonCellDto(int VendorId, int QuotationId, decimal UnitPrice, int LeadDays);
public sealed record RfqComparisonRowDto(int ItemId, decimal Qty, IReadOnlyList<RfqComparisonCellDto> Quotes);
public sealed record RfqComparisonDto(int RfqId, IReadOnlyList<int> VendorIds, IReadOnlyList<RfqComparisonRowDto> Rows);
// Requests ----------------------------------------------------------------------
public sealed class CreateRfqLineInput
{
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateRfqRequest
{
[Required] public int RequisitionId { get; set; }
/// <summary>Vendors the RFQ is issued to (validated for existence; quotations reference them).</summary>
public List<int> VendorIds { get; set; } = new();
[Required, MinLength(1)] public List<CreateRfqLineInput> Lines { get; set; } = new();
}
public sealed class CreateQuotationLineInput
{
[Required] public int ItemId { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
[Range(0, int.MaxValue)] public int LeadDays { get; set; }
}
public sealed class CreateQuotationRequest
{
[Required] public int VendorId { get; set; }
[Required, MinLength(1)] public List<CreateQuotationLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Reference;
/// <summary>Reason code (docs/11 §6).</summary>
public sealed record ReasonCodeDto(int ReasonCodeId, string Code, string Description, ReasonContext Context);
public sealed class CreateReasonCodeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Description { get; set; } = string.Empty;
[Required, EnumDataType(typeof(ReasonContext))] public ReasonContext Context { get; set; }
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.5) ------------------------------------------------------
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateAdjustmentLineInput
{
[Required] public int ItemId { get; set; }
public int? BinId { get; set; }
public int? BatchId { get; set; }
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
public decimal QtyDelta { get; set; }
}
public sealed class CreateAdjustmentRequest
{
[Required] public int WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
}
+33
View File
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.6) ------------------------------------------------------
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateCountRequest
{
[Required] public int WarehouseId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<int> ItemIds { get; set; } = new();
}
public sealed class EnterCountLineInput
{
[Required] public int CountLineId { get; set; }
[Range(0, double.MaxValue)] public decimal CountedQty { get; set; }
}
public sealed class EnterCountsRequest
{
[Required, MinLength(1)] public List<EnterCountLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,6 @@
namespace ERPCore.Dtos.Stock;
/// <summary>An item at/below its reorder point (docs/11 §5.7; FR-STK-10). Computed on read.</summary>
public sealed record ReorderAlertDto(
int ItemId, int WarehouseId, decimal Available,
decimal ReorderPoint, decimal ReorderQty, decimal SuggestedRequisitionQty);
+22
View File
@@ -0,0 +1,22 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand onHold reserved inTransit(out).</summary>
public sealed record StockOnHandDto(
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
public sealed record StockLedgerRowDto(
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
public sealed record StockValuationDto(
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.4) ------------------------------------------------------
public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
public sealed record DispatchResultDto(
int TransferId, TransferStatus Status, IReadOnlyList<ConsumedLayerDto> ConsumedLayers, IReadOnlyList<int> LedgerRefs);
public sealed record TransferCreatedLayerDto(int LayerId, int WarehouseId, decimal QtyReceived, decimal UnitCost);
public sealed record ReceiveResultDto(
int TransferId, TransferStatus Status, IReadOnlyList<TransferCreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateTransferLineInput
{
[Required] public int ItemId { get; set; }
public int? SrcBinId { get; set; }
public int? DestBinId { get; set; }
public int? BatchId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateTransferRequest
{
[Required] public int SrcWarehouseId { get; set; }
[Required] public int DestWarehouseId { get; set; }
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
}
public sealed class ReceiveTransferLineInput
{
[Required] public int TransferLineId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class ReceiveTransferRequest
{
[Required, MinLength(1)] public List<ReceiveTransferLineInput> Lines { get; set; } = new();
}
+1 -1
View File
@@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Uoms; namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary> /// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(long UomId, string Name); public sealed record UomDto(int UomId, string Name);
public sealed class CreateUomRequest public sealed class CreateUomRequest
{ {
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary> /// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto( public sealed record VendorDto(
long VendorId, string Code, string Name, string? Terms, string? TaxReg, int VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest public sealed class CreateVendorRequest
@@ -3,10 +3,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses; namespace ERPCore.Dtos.Warehouses;
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary> /// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public sealed record WarehouseDto(long WarehouseId, string Code, string Name); public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
/// <summary>Bin/location resource (docs/11 §2.5).</summary> /// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(long BinId, long WarehouseId, string Code, string? BinType); public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
public sealed class CreateWarehouseRequest public sealed class CreateWarehouseRequest
{ {
@@ -0,0 +1,17 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Claim type names emitted by the AuthHex IdP (see its <c>JwtTokenHelper</c>).
/// AuthHex uses no standard <c>sub</c>/<c>nameid</c>; identity is the custom
/// <see cref="UserId"/> (GUID). These are read verbatim (JWT bearer is configured
/// with <c>MapInboundClaims = false</c>).
/// </summary>
public static class AuthHexClaims
{
public const string UserId = "UserId";
public const string UserTypeId = "UserTypeId";
public const string UserTypeCode = "UserTypeCode";
public const string RoleId = "RoleId";
public const string RoleCode = "RoleCode";
public const string Nic = "NIC";
}
@@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using ERPCore.Domain.Entities;
namespace ERPCore.Infra.Auth; namespace ERPCore.Infra.Auth;
@@ -28,4 +29,6 @@ public sealed class CurrentUser : ICurrentUser
return string.IsNullOrWhiteSpace(sub) ? SystemActor : sub; return string.IsNullOrWhiteSpace(sub) ? SystemActor : sub;
} }
} }
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
} }
@@ -10,6 +10,13 @@ public interface ICurrentUser
/// <summary>The audit actor identity (token `sub`), or "system" when unauthenticated.</summary> /// <summary>The audit actor identity (token `sub`), or "system" when unauthenticated.</summary>
string UserId { get; } string UserId { get; }
/// <summary>
/// Numeric audit actor for stamping document <c>createdBy</c>/<c>requestedBy</c> FKs.
/// Resolves the token <c>sub</c> to a user id; falls back to the seeded system
/// user (<see cref="Entities.User.SystemUserId"/>) while auth is deferred (§6).
/// </summary>
int AuditUserId { get; }
/// <summary>True when the request carries an authenticated principal.</summary> /// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; } bool IsAuthenticated { get; }
} }
@@ -1,25 +1,42 @@
using System.Text; using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace ERPCore.Infra.Auth; namespace ERPCore.Infra.Auth;
/// <summary> /// <summary>
/// JWT bearer wiring. Authentication only — RBAC/authorization policies are /// Auth wiring for ERPCore as a **resource server** for the external AuthHex IdP
/// deferred for Phase 1; the validated principal exists solely so that /// (docs/10 A.4). Validates AuthHex's **RS256** tokens against AuthHex's RSA public
/// <see cref="ICurrentUser"/> can stamp the audit actor. /// key (configured statically — no JWKS), issuer <c>AuthHex</c>, audience
/// <c>AuthHexClient</c>. A single door policy (<see cref="ErpAccessPolicy"/>) admits
/// only ERP <c>UserType</c>/<c>Role</c> holders when those codes are configured;
/// per-endpoint RBAC stays deferred. Identity → audit actor is resolved by
/// <see cref="ShadowUserClaimsTransformation"/> + <see cref="ICurrentUser"/>.
/// </summary> /// </summary>
public static class JwtAuthExtensions public static class JwtAuthExtensions
{ {
/// <summary>Authorization policy applied to every v1 controller (via ApiControllerBase).</summary>
public const string ErpAccessPolicy = "ErpAccess";
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config) public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
{ {
var issuer = config["Jwt:Issuer"]; var issuer = config["Auth:Issuer"];
var audience = config["Jwt:Audience"]; var audience = config["Auth:Audience"];
var signingKey = config["Jwt:SigningKey"] ?? string.Empty; var publicKeyXml = config["Auth:RsaPublicKeyXml"]
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
var requiredUserType = config["Auth:RequiredUserTypeCode"];
var requiredRole = config["Auth:RequiredRoleCode"];
// AuthHex publishes no JWKS; the RSA public key is configured statically.
var rsa = RSA.Create();
rsa.FromXmlString(publicKeyXml);
var signingKey = new RsaSecurityKey(rsa);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
// Keep AuthHex's claim names verbatim (UserId, UserTypeCode, RoleCode …).
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
@@ -28,12 +45,26 @@ public static class JwtAuthExtensions
ValidAudience = audience, ValidAudience = audience,
ValidateLifetime = true, ValidateLifetime = true,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), IssuerSigningKey = signingKey,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromSeconds(30) ClockSkew = TimeSpan.FromSeconds(30)
}; };
}); });
services.AddAuthorization(); services.AddAuthorization(options =>
{
options.AddPolicy(ErpAccessPolicy, policy =>
{
policy.RequireAuthenticatedUser();
// Door gate: only enforce a UserType/Role when configured (AuthHex is a
// shared IdP). Empty config = require a valid ERP token only.
if (!string.IsNullOrWhiteSpace(requiredUserType))
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
if (!string.IsNullOrWhiteSpace(requiredRole))
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
});
});
return services; return services;
} }
} }
@@ -0,0 +1,76 @@
using System.Security.Claims;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
/// <c>int</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
/// </summary>
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{
private readonly ErpDbContext _db;
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
return principal;
if (identity.HasClaim(c => c.Type == ClaimTypes.NameIdentifier))
return principal; // already resolved this request
var raw = principal.FindFirstValue(AuthHexClaims.UserId);
if (!Guid.TryParse(raw, out var authUserId))
return principal; // no mappable identity → CurrentUser falls back to system
var nic = principal.FindFirstValue(AuthHexClaims.Nic);
var localId = await ResolveOrProvisionAsync(authUserId, nic);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
return principal;
}
private async Task<int> ResolveOrProvisionAsync(Guid authUserId, string? nic)
{
var existing = await _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.FirstOrDefaultAsync();
if (existing != 0) return existing;
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
var user = new User
{
AuthUserId = authUserId,
Username = label,
DisplayName = string.IsNullOrWhiteSpace(nic) ? "AuthHex User" : nic.Trim(),
Status = EntityStatus.Active
};
try
{
_db.Users.Add(user);
await _db.SaveChangesAsync();
return user.UserId;
}
catch (DbUpdateException)
{
// Lost a race (unique auth_user_id) — the row now exists; re-read it.
_db.Entry(user).State = EntityState.Detached;
return await _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.FirstAsync();
}
}
}
@@ -0,0 +1,98 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace ERPCore.Infra.Persistence.Auditing;
/// <summary>A mutation captured before save, awaiting its (possibly generated) key.</summary>
public sealed record PendingAudit(EntityEntry Entry, string EntityType, AuditAction Action, string ChangeSet, int CapturedId, bool IsAdded);
/// <summary>
/// Builds audit-trail rows from the EF change tracker (FR-X-02). High-volume /
/// derived / self-referential tables are excluded (the stock ledger is itself the
/// stock movement audit). Change sets are captured <b>before</b> save so old→new is
/// accurate; generated keys for inserts are read <b>after</b> save.
/// </summary>
public static class AuditScribe
{
private static readonly HashSet<Type> Excluded =
[
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
typeof(StockLedger), typeof(StockLayer),
];
private static readonly JsonSerializerOptions Json = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static List<PendingAudit> Capture(ChangeTracker tracker)
{
var pending = new List<PendingAudit>();
foreach (var entry in tracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) continue;
if (Excluded.Contains(entry.Entity.GetType())) continue;
var action = entry.State switch
{
EntityState.Added => AuditAction.Create,
EntityState.Deleted => AuditAction.Delete,
_ => AuditAction.Update,
};
var changeSet = BuildChangeSet(entry, action);
if (action == AuditAction.Update && changeSet == "{}") continue; // only concurrency token touched, etc.
var isAdded = entry.State == EntityState.Added;
pending.Add(new PendingAudit(entry, entry.Entity.GetType().Name, action, changeSet, isAdded ? 0 : ReadKey(entry), isAdded));
}
return pending;
}
public static AuditLog ToLog(PendingAudit p, int userId, DateTime now) => new()
{
UserId = userId,
EntityType = p.EntityType,
EntityId = p.IsAdded ? ReadKey(p.Entry) : p.CapturedId,
Action = p.Action,
ChangeSet = p.ChangeSet,
CreatedAt = now,
};
private static int ReadKey(EntityEntry entry)
{
var pk = entry.Metadata.FindPrimaryKey();
if (pk is null || pk.Properties.Count != 1) return 0;
var value = entry.Property(pk.Properties[0].Name).CurrentValue;
return value is null ? 0 : Convert.ToInt32(value);
}
private static string BuildChangeSet(EntityEntry entry, AuditAction action)
{
var set = new Dictionary<string, object?>();
foreach (var p in entry.Properties)
{
if (p.Metadata.IsPrimaryKey()) continue;
if (p.Metadata.Name == nameof(Item.RowVersion)) continue;
switch (action)
{
case AuditAction.Create when p.CurrentValue is not null:
set[p.Metadata.Name] = p.CurrentValue;
break;
case AuditAction.Delete:
set[p.Metadata.Name] = p.OriginalValue;
break;
case AuditAction.Update when p.IsModified && !Equals(p.OriginalValue, p.CurrentValue):
set[p.Metadata.Name] = new Dictionary<string, object?> { ["old"] = p.OriginalValue, ["new"] = p.CurrentValue };
break;
}
}
return JsonSerializer.Serialize(set, Json);
}
}
@@ -0,0 +1,41 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
{
public void Configure(EntityTypeBuilder<AuditLog> builder)
{
builder.ToTable("audit_logs");
builder.HasKey(a => a.AuditId);
builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80);
builder.Property(a => a.Action).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb");
builder.Property(a => a.CreatedAt).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => new { a.EntityType, a.EntityId });
builder.HasIndex(a => a.CreatedAt);
builder.HasIndex(a => a.UserId);
}
}
public sealed class JournalEntryStubConfiguration : IEntityTypeConfiguration<JournalEntryStub>
{
public void Configure(EntityTypeBuilder<JournalEntryStub> builder)
{
builder.ToTable("journal_entry_stubs");
builder.HasKey(j => j.JournalId);
builder.Property(j => j.SourceDocType).IsRequired().HasMaxLength(10);
builder.Property(j => j.DebitAccount).IsRequired().HasMaxLength(20);
builder.Property(j => j.CreditAccount).IsRequired().HasMaxLength(20);
builder.Property(j => j.Amount).HasPrecision(18, 4);
builder.HasIndex(j => new { j.SourceDocType, j.SourceDocId });
}
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BatchConfiguration : IEntityTypeConfiguration<Batch>
{
public void Configure(EntityTypeBuilder<Batch> builder)
{
builder.ToTable("batches");
builder.HasKey(b => b.BatchId);
builder.Property(b => b.BatchNo).IsRequired().HasMaxLength(50);
builder.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.ItemId)
.OnDelete(DeleteBehavior.Restrict);
// Batch number unique within an item.
builder.HasIndex(b => new { b.ItemId, b.BatchNo }).IsUnique();
}
}
public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
{
public void Configure(EntityTypeBuilder<Serial> builder)
{
builder.ToTable("serials");
builder.HasKey(s => s.SerialId);
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.Property(s => s.Status).IsRequired().HasMaxLength(20);
builder.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique();
}
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
{
public void Configure(EntityTypeBuilder<Grn> builder)
{
builder.ToTable("grns");
builder.HasKey(g => g.GrnId);
builder.Property(g => g.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(g => g.DocNo).IsUnique();
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
}
}
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
{
public void Configure(EntityTypeBuilder<GrnLine> builder)
{
builder.ToTable("grn_lines");
builder.HasKey(l => l.GrnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.ReceivedValue).HasPrecision(18, 4);
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NumberSequenceConfiguration : IEntityTypeConfiguration<NumberSequence>
{
public void Configure(EntityTypeBuilder<NumberSequence> builder)
{
builder.ToTable("number_sequences");
builder.HasKey(s => s.SequenceId);
builder.Property(s => s.DocType).HasColumnName("doc_type").IsRequired().HasMaxLength(10);
builder.Property(s => s.Year).HasColumnName("year").IsRequired();
builder.Property(s => s.LastNumber).HasColumnName("last_number").IsRequired();
// One counter per (doc type, year); also the ON CONFLICT target for atomic issue.
builder.HasIndex(s => new { s.DocType, s.Year }).IsUnique();
}
}
@@ -0,0 +1,76 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration<PurchaseOrder>
{
public void Configure(EntityTypeBuilder<PurchaseOrder> builder)
{
builder.ToTable("purchase_orders");
builder.HasKey(p => p.PoId);
builder.Property(p => p.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(p => p.DocNo).IsUnique();
builder.Property(p => p.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(p => p.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(p => p.RowVersion).IsRowVersion();
builder.HasOne(p => p.Vendor)
.WithMany()
.HasForeignKey(p => p.VendorId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Requisition)
.WithMany()
.HasForeignKey(p => p.RequisitionId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator)
.WithMany()
.HasForeignKey(p => p.CreatedBy)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.Status);
builder.HasIndex(p => p.VendorId);
}
}
public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
{
public void Configure(EntityTypeBuilder<PoLine> builder)
{
builder.ToTable("po_lines");
builder.HasKey(l => l.PoLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.Property(l => l.Tax).HasPrecision(9, 4);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
builder.HasOne(l => l.PurchaseOrder)
.WithMany(p => p.Lines)
.HasForeignKey(l => l.PoId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom)
.WithMany()
.HasForeignKey(l => l.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Warehouse)
.WithMany()
.HasForeignKey(l => l.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration<PurchaseReturn>
{
public void Configure(EntityTypeBuilder<PurchaseReturn> builder)
{
builder.ToTable("purchase_returns");
builder.HasKey(r => r.ReturnId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class PurchaseReturnLineConfiguration : IEntityTypeConfiguration<PurchaseReturnLine>
{
public void Configure(EntityTypeBuilder<PurchaseReturnLine> builder)
{
builder.ToTable("purchase_return_lines");
builder.HasKey(l => l.ReturnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class ReasonCodeConfiguration : IEntityTypeConfiguration<ReasonCode>
{
public void Configure(EntityTypeBuilder<ReasonCode> builder)
{
builder.ToTable("reason_codes");
builder.HasKey(r => r.ReasonCodeId);
builder.Property(r => r.Code).IsRequired().HasMaxLength(20);
builder.Property(r => r.Description).IsRequired().HasMaxLength(200);
builder.Property(r => r.Context).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasIndex(r => new { r.Context, r.Code }).IsUnique();
}
}
@@ -0,0 +1,49 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RequisitionConfiguration : IEntityTypeConfiguration<Requisition>
{
public void Configure(EntityTypeBuilder<Requisition> builder)
{
builder.ToTable("requisitions");
builder.HasKey(r => r.RequisitionId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Requester)
.WithMany()
.HasForeignKey(r => r.RequestedBy)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(r => r.Status);
}
}
public sealed class RequisitionLineConfiguration : IEntityTypeConfiguration<RequisitionLine>
{
public void Configure(EntityTypeBuilder<RequisitionLine> builder)
{
builder.ToTable("requisition_lines");
builder.HasKey(l => l.ReqLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Requisition)
.WithMany(r => r.Lines)
.HasForeignKey(l => l.RequisitionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,92 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RfqConfiguration : IEntityTypeConfiguration<Rfq>
{
public void Configure(EntityTypeBuilder<Rfq> builder)
{
builder.ToTable("rfqs");
builder.HasKey(r => r.RfqId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Requisition)
.WithMany()
.HasForeignKey(r => r.RequisitionId)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class RfqLineConfiguration : IEntityTypeConfiguration<RfqLine>
{
public void Configure(EntityTypeBuilder<RfqLine> builder)
{
builder.ToTable("rfq_lines");
builder.HasKey(l => l.RfqLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Rfq)
.WithMany(r => r.Lines)
.HasForeignKey(l => l.RfqId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class VendorQuotationConfiguration : IEntityTypeConfiguration<VendorQuotation>
{
public void Configure(EntityTypeBuilder<VendorQuotation> builder)
{
builder.ToTable("vendor_quotations");
builder.HasKey(q => q.QuotationId);
builder.Property(q => q.CreatedAt).IsRequired();
builder.HasOne(q => q.Rfq)
.WithMany(r => r.Quotations)
.HasForeignKey(q => q.RfqId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(q => q.Vendor)
.WithMany()
.HasForeignKey(q => q.VendorId)
.OnDelete(DeleteBehavior.Restrict);
// One quotation per vendor per RFQ.
builder.HasIndex(q => new { q.RfqId, q.VendorId }).IsUnique();
}
}
public sealed class VendorQuotationLineConfiguration : IEntityTypeConfiguration<VendorQuotationLine>
{
public void Configure(EntityTypeBuilder<VendorQuotationLine> builder)
{
builder.ToTable("vendor_quotation_lines");
builder.HasKey(l => l.QuotationLineId);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.HasOne(l => l.Quotation)
.WithMany(q => q.Lines)
.HasForeignKey(l => l.QuotationId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<StockAdjustment>
{
public void Configure(EntityTypeBuilder<StockAdjustment> builder)
{
builder.ToTable("stock_adjustments");
builder.HasKey(a => a.AdjustmentId);
builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(a => a.DocNo).IsUnique();
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.Property(a => a.RowVersion).IsRowVersion();
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<StockAdjustmentLine>
{
public void Configure(EntityTypeBuilder<StockAdjustmentLine> builder)
{
builder.ToTable("stock_adjustment_lines");
builder.HasKey(l => l.AdjLineId);
builder.Property(l => l.QtyDelta).HasPrecision(18, 4);
builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,60 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class StockLayerConfiguration : IEntityTypeConfiguration<StockLayer>
{
public void Configure(EntityTypeBuilder<StockLayer> builder)
{
builder.ToTable("stock_layers");
builder.HasKey(l => l.LayerId);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
builder.Property(l => l.QtyRemaining).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.ReceiptDate).IsRequired();
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Warehouse).WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict);
// FIFO consumption orders by receipt date then layer id, scoped per item+warehouse.
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.ReceiptDate, l.LayerId });
builder.HasIndex(l => l.GrnLineId);
}
}
public sealed class StockLedgerConfiguration : IEntityTypeConfiguration<StockLedger>
{
public void Configure(EntityTypeBuilder<StockLedger> builder)
{
// Append-only (FR-STK-01/FR-X-05): the app never updates/deletes ledger rows.
// DB-level revocation of UPDATE/DELETE is a deferred hardening step (02-SECURITY B.3).
builder.ToTable("stock_ledger");
builder.HasKey(l => l.LedgerId);
builder.Property(l => l.Direction).HasConversion<string>().HasMaxLength(5).IsRequired();
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.Value).HasPrecision(18, 4);
builder.Property(l => l.RunningBalance).HasPrecision(18, 4);
builder.Property(l => l.SourceDocType).IsRequired().HasMaxLength(10);
builder.Property(l => l.CreatedAt).IsRequired();
builder.HasOne<Item>().WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Warehouse>().WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
// Time-series query paths (NFR-06) and polymorphic source tracing.
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.LedgerId });
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.CreatedAt });
builder.HasIndex(l => new { l.SourceDocType, l.SourceDocId });
}
}
@@ -0,0 +1,44 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCount>
{
public void Configure(EntityTypeBuilder<StockCount> builder)
{
builder.ToTable("stock_counts");
builder.HasKey(c => c.CountId);
builder.Property(c => c.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(c => c.DocNo).IsUnique();
builder.Property(c => c.CountType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.Status);
}
}
public sealed class StockCountLineConfiguration : IEntityTypeConfiguration<StockCountLine>
{
public void Configure(EntityTypeBuilder<StockCountLine> builder)
{
builder.ToTable("stock_count_lines");
builder.HasKey(l => l.CountLineId);
builder.Property(l => l.SystemQty).HasPrecision(18, 4);
builder.Property(l => l.CountedQty).HasPrecision(18, 4);
builder.Property(l => l.Variance).HasPrecision(18, 4);
builder.HasOne(l => l.Count).WithMany(c => c.Lines).HasForeignKey(l => l.CountId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
}
}

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