Compare commits

...

9 Commits

Author SHA1 Message Date
ImanThiyanga ae7627fcf2 Merge branch 'Dev' into fix/phase-01 2026-07-14 11:02:44 +00:00
ImanThiyanga 67150425e4 Add migration to add auth_user_id column to users table
- Introduced a new column 'auth_user_id' of type UUID to the 'users' table.
- Updated existing user data to set 'auth_user_id' to null for UserId 1.
- Created a unique index on 'auth_user_id' to enforce uniqueness.
- Implemented rollback functionality to remove the column and index if needed.
2026-07-14 16:30:45 +05:30
ImanThiyanga 9e1aa57987 Merge pull request 'feat: Add new services and interfaces for GRN, Purchase Return, Reason Code, Reorder, Stock, and Transfer functionalities' (#3) from fix/phase-01 into Dev
Reviewed-on: #3
2026-07-14 05:12:25 +00:00
ImanThiyanga 22f86451e3 feat: Add new services and interfaces for GRN, Purchase Return, Reason Code, Reorder, Stock, and Transfer functionalities
- Implemented IGrnService for managing goods receipts including retrieval, creation, and confirmation.
- Created IPurchaseReturnService for handling purchase return operations.
- Added IReasonCodeService for managing reason codes with listing and creation capabilities.
- Developed IReorderService for fetching reorder alerts and creating suggested requisitions.
- Introduced IStockMutator for applying stock changes and posting ledger entries.
- Established IStockService for stock inquiries, ledger retrieval, and valuation.
- Created ITransferService for managing inter-warehouse transfers including dispatch and receiving operations.
- Implemented PurchaseReturnService to handle purchase return logic and stock adjustments.
- Developed ReasonCodeService for listing and creating reason codes.
- Created ReorderService for fetching reorder alerts and generating requisitions.
- Implemented FifoCostingService for FIFO cost-layer management and ledger writing.
- Developed StockMutator for applying stock deltas and posting ledger entries.
- Created StockService for stock inquiries and ledger management.
- Implemented TransferService for managing inter-warehouse transfers with dispatch and receive functionalities.
2026-07-14 10:20:38 +05:30
ImanThiyanga 4e84a15db7 feat: Implement procurement services and number sequence management
- Added INumberSequenceService interface for generating document numbers.
- Created NumberSequenceService to handle atomic document number issuance.
- Introduced IPurchaseOrderService interface and implemented PurchaseOrderService for managing purchase orders.
- Added IRequisitionService interface and implemented RequisitionService for handling requisitions.
- Created IRfqService interface and implemented RfqService for managing RFQs and vendor quotations.
- Defined necessary DTOs and domain entities for procurement processes.
- Ensured proper validation and error handling across services.
2026-07-13 10:28:58 +05:30
ImanThiyanga 783696fa97 smoke test complete on featues 2026-07-10 11:55:32 +05:30
ImanThiyanga 08a4c28868 Merge branch 'production' into feat/phase-implemtation 2026-07-10 05:15:52 +00:00
ImanThiyanga badb26a81f Merge pull request 'feat: enhance UI components and implement password reset flow with improved styling and validation' (#1) from Sasanka/auth into production
Reviewed-on: #1
2026-07-10 05:15:31 +00:00
ImanThiyanga 057dd5aedc feat: Implement Category, Item, UOM, Vendor, and Warehouse services with CRUD operations
- Added CategoryService for managing categories with listing, tree structure, and creation functionalities.
- Introduced ItemService for item management, including listing, detail retrieval, creation, updating, and status management.
- Created UomService for handling unit of measure operations, including listing and creation.
- Developed VendorService for vendor management, supporting listing, detail retrieval, creation, updating, and status management.
- Implemented WarehouseService for warehouse and bin management, including listing warehouses, creating warehouses, and managing bins within warehouses.
- Added interfaces for each service to define the contract for service implementations.
- Generated Entity Framework Core model snapshot for database migrations.
2026-07-10 10:44:30 +05:30
185 changed files with 22904 additions and 77 deletions
+37
View File
@@ -0,0 +1,37 @@
namespace ERPCore.Common.Http;
/// <summary>
/// Encodes the PostgreSQL xmin concurrency token (a <see cref="uint"/>) as an
/// opaque, quoted HTTP ETag and parses <c>If-Match</c> values back. Round-trips
/// via base64 so the value is stable and content-type agnostic
/// (docs/11-BACKEND-PHASE1.md §1.6).
/// </summary>
public static class ETag
{
/// <summary>Quoted ETag string for a row-version token, e.g. <c>"0RsAAA=="</c>.</summary>
public static string From(uint rowVersion)
=> "\"" + Convert.ToBase64String(BitConverter.GetBytes(rowVersion)) + "\"";
/// <summary>Parse an <c>If-Match</c> header value (quoted, optionally weak) to a token.</summary>
public static bool TryParse(string? ifMatch, out uint rowVersion)
{
rowVersion = 0;
if (string.IsNullOrWhiteSpace(ifMatch)) return false;
var v = ifMatch.Trim();
if (v.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) v = v[2..].Trim();
v = v.Trim('"');
try
{
var bytes = Convert.FromBase64String(v);
if (bytes.Length != sizeof(uint)) return false;
rowVersion = BitConverter.ToUInt32(bytes);
return true;
}
catch (FormatException)
{
return false;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace ERPCore.Common.Http;
/// <summary>
/// Pairs a response DTO with the aggregate's current row-version so the controller
/// can emit an <c>ETag</c> header without the token leaking into the JSON body.
/// </summary>
public sealed record ETagged<T>(T Value, uint RowVersion);
@@ -0,0 +1,32 @@
using ERPCore.Common.Http;
using ERPCore.Infra.Auth;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Base for the v1 API controllers. Centralises ETag / If-Match handling
/// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform.
/// Each controller declares its own explicit lowercase <c>[Route]</c> to match
/// the API contract paths (docs/11 §1.1). Every v1 endpoint requires a valid
/// AuthHex token satisfying the ERP door policy (docs/10 A.4).
/// </summary>
[ApiController]
[Produces("application/json")]
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
public abstract class ApiControllerBase : ControllerBase
{
/// <summary>Parse a mandatory <c>If-Match</c> header, or 428 if absent/malformed.</summary>
protected uint RequireIfMatch()
{
var header = Request.Headers.IfMatch.ToString();
if (!ETag.TryParse(header, out var rowVersion))
throw new DomainException("PRECONDITION_REQUIRED", "A valid If-Match header is required for this update.", 428);
return rowVersion;
}
/// <summary>Emit the strong <c>ETag</c> response header for a row-version token.</summary>
protected void SetETag(uint rowVersion) => Response.Headers.ETag = ETag.From(rowVersion);
}
@@ -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] long? entityId, [FromQuery] long? 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,31 @@
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
[Route("api/v1/categories")]
public sealed class CategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
public CategoriesController(ICategoryService categories) => _categories = categories;
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
[HttpPost]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
{
var dto = await _categories.CreateAsync(request, ct);
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
}
}
@@ -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:long}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GrnDto>> GetById(long 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:long}/confirm")]
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GrnConfirmResultDto>> Confirm(
long 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:long}/lines/{grnLineId:long}/release")]
[ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ReleaseLineResultDto>> Release(
long grnId, long grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
}
@@ -0,0 +1,89 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.12.2).</summary>
[Route("api/v1/items")]
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
public ItemsController(IItemService items) => _items = items;
/// <summary>List items with optional filters and paging.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ItemListItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemListItemDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] long? categoryId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:long}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemDetailDto>> GetById(long itemId, CancellationToken ct)
{
var result = await _items.GetAsync(itemId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Create an item (SKU unique). Server sets status and timestamps.</summary>
[HttpPost]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemDetailDto>> Create([FromBody] CreateItemRequest request, CancellationToken ct)
{
var result = await _items.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/items/{result.Value.ItemId}", result.Value);
}
/// <summary>Full update; requires <c>If-Match</c> (412 on stale ETag).</summary>
[HttpPut("{itemId:long}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ItemDetailDto>> Update(long itemId, [FromBody] UpdateItemRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _items.UpdateAsync(itemId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Activate / deactivate the item (FR-MD-08 — deactivate, not delete).</summary>
[HttpPatch("{itemId:long}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(long itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct)
{
await _items.SetStatusAsync(itemId, request.Status, ct);
return NoContent();
}
/// <summary>Replace the item's per-warehouse reorder settings (FR-MD-05).</summary>
[HttpPut("{itemId:long}/reorder")]
[ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(long itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
[HttpPut("{itemId:long}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(long itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken 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] long? 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] long? vendorId, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
[HttpGet("{poId:long}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseOrderDto>> GetById(long 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:long}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseOrderDto>> Update(long 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:long}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseOrderDto>> Approve(long poId, CancellationToken ct)
=> Ok(await _pos.ApproveAsync(poId, ct));
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
[HttpPost("{poId:long}/cancel")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Cancel(long 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:long}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RequisitionDto>> GetById(long 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:long}/submit")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RequisitionDto>> Submit(long 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:long}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RfqDto>> GetById(long 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:long}/quotations")]
[ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<VendorQuotationDto>> AddQuotation(long 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:long}/comparison")]
[ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RfqComparisonDto>> Comparison(long 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] long itemId, [FromQuery] long 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] long? itemId, [FromQuery] long? 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] long itemId, [FromQuery] long 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] long? 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:long}/requisition")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RequisitionDto>> SuggestRequisition(
long itemId, [FromQuery] long 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:long}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CountDto>> GetById(long 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:long}/counts")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CountDto>> EnterCounts(long 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:long}/post")]
[ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CountPostResultDto>> Post(long 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:long}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TransferDto>> GetById(long 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:long}/dispatch")]
[ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DispatchResultDto>> Dispatch(long transferId, CancellationToken ct)
=> Ok(await _transfers.DispatchAsync(transferId, ct));
/// <summary>Receive: create the destination layer at the inherited cost (cost-preserving).</summary>
[HttpPost("{transferId:long}/receive")]
[ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ReceiveResultDto>> Receive(long transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
=> Ok(await _transfers.ReceiveAsync(transferId, request, ct));
}
@@ -0,0 +1,29 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
[Route("api/v1/uoms")]
public sealed class UomsController : ApiControllerBase
{
private readonly IUomService _uoms;
public UomsController(IUomService uoms) => _uoms = uoms;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<UomDto>> Create([FromBody] CreateUomRequest request, CancellationToken ct)
{
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
}
@@ -0,0 +1,65 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
[Route("api/v1/vendors")]
public sealed class VendorsController : ApiControllerBase
{
private readonly IVendorService _vendors;
public VendorsController(IVendorService vendors) => _vendors = vendors;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<VendorDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<VendorDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _vendors.ListAsync(query, status, ct));
[HttpGet("{vendorId:long}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VendorDto>> GetById(long vendorId, CancellationToken ct)
{
var result = await _vendors.GetAsync(vendorId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<VendorDto>> Create([FromBody] CreateVendorRequest request, CancellationToken ct)
{
var result = await _vendors.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
}
[HttpPut("{vendorId:long}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<VendorDto>> Update(long vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _vendors.UpdateAsync(vendorId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{vendorId:long}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(long vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
{
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,54 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Warehouse &amp; bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
[Route("api/v1/warehouses")]
public sealed class WarehousesController : ApiControllerBase
{
private readonly IWarehouseService _warehouses;
public WarehousesController(IWarehouseService warehouses) => _warehouses = warehouses;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct));
[HttpGet("{warehouseId:long}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WarehouseDto>> GetById(long warehouseId, CancellationToken ct)
{
var dto = await _warehouses.GetAsync(warehouseId, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WarehouseDto>> Create([FromBody] CreateWarehouseRequest request, CancellationToken ct)
{
var dto = await _warehouses.CreateAsync(request, ct);
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
}
[HttpGet("{warehouseId:long}/bins")]
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<BinDto>>> ListBins(long warehouseId, CancellationToken ct)
=> Ok(await _warehouses.ListBinsAsync(warehouseId, ct));
[HttpPost("{warehouseId:long}/bins")]
[ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BinDto>> CreateBin(long warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct)
{
var dto = await _warehouses.CreateBinAsync(warehouseId, request, ct);
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 long AuditId { get; set; }
public long UserId { get; set; }
public string EntityType { get; set; } = string.Empty;
public long 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 long BatchId { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public string BatchNo { get; set; } = string.Empty;
public DateOnly? ExpiryDate { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Bin / storage location within a warehouse (FR-MD-07, FR-WH-02). Stock is
/// tracked to bin level. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Bin
{
public long BinId { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public string Code { get; set; } = string.Empty;
public string? BinType { get; set; }
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Category
{
public long CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public long? ParentId { get; set; }
public Category? Parent { get; set; }
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 long GrnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long? PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
public long VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public long 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 long GrnLineId { get; set; }
public long GrnId { get; set; }
public Grn? Grn { get; set; }
public long? PoLineId { get; set; }
public PoLine? PoLine { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long UomId { get; set; }
public Uom? Uom { get; set; }
public long? BinId { get; set; }
public Bin? Bin { get; set; }
public long? 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;
}
+39
View File
@@ -0,0 +1,39 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Item master (FR-MD-01). Mutable aggregate: carries a <see cref="RowVersion"/>
/// concurrency token surfaced as an ETag (docs/10 Part C.10). SKU is unique.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Item
{
public long ItemId { get; set; }
public string Sku { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public long CategoryId { get; set; }
public Category? Category { get; set; }
public long BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
public long? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; }
public ItemType ItemType { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
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<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Reorder policy for an item, optionally per warehouse (FR-MD-05). Reorder alerts
/// are computed from these versus available stock (FR-STK-10) — not stored.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ItemReorder
{
public long ReorderId { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal ReorderPoint { get; set; }
public decimal ReorderQty { 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 long JournalId { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public long 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 long SequenceId { get; set; }
public string DocType { get; set; } = string.Empty;
public int Year { get; set; }
public long 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 long PoLineId { get; set; }
public long PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long UomId { get; set; }
public Uom? Uom { get; set; }
public long 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 long PoId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long? RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
public bool ApprovalRequired { get; set; }
public long 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 long ReturnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
public long 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 long ReturnLineId { get; set; }
public long ReturnId { get; set; }
public PurchaseReturn? Return { get; set; }
public long? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public long 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 long 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 long RequisitionId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long 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 long ReqLineId { get; set; }
public long RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public long 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 long RfqId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long 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 long RfqLineId { get; set; }
public long RfqId { get; set; }
public Rfq? Rfq { get; set; }
public long 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 long SerialId { get; set; }
public long 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 long AdjustmentId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public long 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 long AdjLineId { get; set; }
public long AdjustmentId { get; set; }
public StockAdjustment? Adjustment { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
public long? 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 long CountId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
public long 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 long CountLineId { get; set; }
public long CountId { get; set; }
public StockCount? Count { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long? 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 long LayerId { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long? BatchId { get; set; }
public Batch? Batch { get; set; }
public long? SerialId { get; set; }
public Serial? Serial { get; set; }
/// <summary>Originating GRN line — carries the inspection hold status for this stock.</summary>
public long? 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 long LedgerId { get; set; }
public long ItemId { get; set; }
public long WarehouseId { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
public long? SerialId { get; set; }
public long 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 long 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 long TransferId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long SrcWarehouseId { get; set; }
public Warehouse? SrcWarehouse { get; set; }
public long DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public long 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 long TransferLineId { get; set; }
public long TransferId { get; set; }
public StockTransfer? Transfer { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long? SrcBinId { get; set; }
public long? DestBinId { get; set; }
public long? BatchId { get; set; }
public long? 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; }
}
+11
View File
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Uom
{
public long UomId { get; set; }
public string Name { get; set; } = string.Empty;
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class UomConversion
{
public long ConversionId { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long FromUomId { get; set; }
public Uom? FromUom { get; set; }
public long ToUomId { get; set; }
public Uom? ToUom { 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"/> (long) 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 long SystemUserId = 1;
public long 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; }
}
+25
View File
@@ -0,0 +1,25 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Vendor master (FR-MD-06). Mutable aggregate with a <see cref="RowVersion"/>
/// ETag token. Deactivated, not deleted, when referenced (FR-MD-08).
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Vendor
{
public long VendorId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Terms { get; set; }
public string? TaxReg { get; set; }
public string Currency { get; set; } = "LKR";
public EntityStatus Status { get; set; } = EntityStatus.Active;
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; }
}
@@ -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 long QuotationId { get; set; }
public long RfqId { get; set; }
public Rfq? Rfq { get; set; }
public long 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 long QuotationLineId { get; set; }
public long QuotationId { get; set; }
public VendorQuotation? Quotation { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public decimal UnitPrice { get; set; }
public int LeadDays { get; set; }
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Warehouse master (FR-MD-07, FR-WH-01). Owns a bin/location hierarchy.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Warehouse
{
public long WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public ICollection<Bin> Bins { get; set; } = new List<Bin>();
}
@@ -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,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle status for deactivatable master data (Item, Vendor). Masters are
/// never hard-deleted while referenced — they are set <see cref="Inactive"/>
/// instead (FR-MD-08). Stored as a string.
/// </summary>
public enum EntityStatus
{
Active,
Inactive
}
@@ -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
}
+12
View File
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Item classification (FR-MD-01). Values match the <c>itemType</c> enum in
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
/// </summary>
public enum ItemType
{
Stocked,
NonStocked,
Service
}
@@ -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,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// How on-hand units of an item are individually tracked (FR-MD-01). Values match
/// the <c>trackingMode</c> enum in docs/11-BACKEND-PHASE1.md §8. Stored as a string.
/// </summary>
public enum TrackingMode
{
None,
Batch,
Serial
}
@@ -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(
long AuditId, long UserId, string EntityType, long 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(
long JournalId, string SourceDocType, long SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Categories;
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public sealed record CategoryDto(long CategoryId, string Name, long? ParentId);
/// <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 class CreateCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public long? ParentId { get; set; }
}
+37
View File
@@ -0,0 +1,37 @@
namespace ERPCore.Dtos.Common;
/// <summary>
/// Shared paging/sorting query binding (docs/11-BACKEND-PHASE1.md §1.5). Page size
/// is clamped to <see cref="MaxPageSize"/> to enforce pagination bounds
/// (02-SECURITY B.6). Bind from the query string on list endpoints.
/// </summary>
public class PageQuery
{
public const int MaxPageSize = 200;
public const int DefaultPageSize = 20;
private int _page = 1;
private int _pageSize = DefaultPageSize;
/// <summary>1-based page number (default 1).</summary>
public int Page
{
get => _page;
set => _page = value < 1 ? 1 : value;
}
/// <summary>Page size (default 20, clamped to 1..200).</summary>
public int PageSize
{
get => _pageSize;
set => _pageSize = value < 1 ? DefaultPageSize : Math.Min(value, MaxPageSize);
}
/// <summary>Free-text search term (<c>q</c>).</summary>
public string? Q { get; set; }
/// <summary>Sort spec, e.g. <c>name</c> or <c>-createdAt</c>.</summary>
public string? Sort { get; set; }
public int Skip => (Page - 1) * PageSize;
}
@@ -0,0 +1,17 @@
namespace ERPCore.Dtos.Common;
/// <summary>
/// List envelope matching docs/11-BACKEND-PHASE1.md §1.4:
/// <c>{ "items": [...], "pagination": { page, pageSize, totalItems, totalPages } }</c>.
/// </summary>
public sealed record PagedResponse<T>(IReadOnlyList<T> Items, PaginationDto Pagination)
{
public static PagedResponse<T> Create(IReadOnlyList<T> items, int page, int pageSize, int totalItems)
{
var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(totalItems / (double)pageSize);
return new PagedResponse<T>(items, new PaginationDto(page, pageSize, totalItems, totalPages));
}
}
/// <summary>Pagination metadata block (docs/11 §1.4).</summary>
public sealed record PaginationDto(int Page, int PageSize, int TotalItems, int TotalPages);
+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(
long GrnLineId, long? PoLineId, long ItemId, long UomId, long? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, long? BatchId);
public sealed record GrnDto(
long GrnId, string DocNo, long? PoId, long VendorId, long WarehouseId, GrnStatus Status,
long CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
public sealed record CreatedLayerDto(
long LayerId, long ItemId, long WarehouseId, long? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
long GrnId, GrnStatus Status, DateTime PostedAt,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(long 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 long? PoLineId { get; set; }
[Required] public long ItemId { get; set; }
[Required] public long UomId { get; set; }
public long? 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 long? PoId { get; set; }
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public long? VendorId { get; set; }
[Required] public long 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";
}
+89
View File
@@ -0,0 +1,89 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
// Response DTOs (docs/11-BACKEND-PHASE1.md §2.1) --------------------------------
/// <summary>Row shape for <c>GET /items</c>.</summary>
public sealed record ItemListItemDto(
long ItemId, string Sku, string Name, long CategoryId, long BaseUomId,
long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(long WarehouseId, decimal ReorderPoint, decimal ReorderQty);
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
public sealed record ItemDetailDto(
long ItemId, string Sku, string Name, string? Description, long CategoryId,
long BaseUomId, long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
public sealed record UomConversionDto(long ConversionId, long FromUom, long ToUom, decimal Factor);
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
public sealed record ItemUomConversionsDto(long ItemId, long BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
public sealed class CreateItemRequest
{
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
[Required] public long CategoryId { get; set; }
[Required] public long BaseUomId { get; set; }
public long? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
public sealed class UpdateItemRequest
{
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
[Required] public long CategoryId { get; set; }
[Required] public long BaseUomId { get; set; }
public long? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
public sealed class UpdateItemStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
public sealed class ReorderSettingInput
{
[Required] public long WarehouseId { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderPoint { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderQty { get; set; }
}
public sealed class UpdateReorderRequest
{
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
}
public sealed class UomConversionInput
{
[Required] public long FromUom { get; set; }
[Required] public long ToUom { get; set; }
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
}
public sealed class UpdateUomConversionsRequest
{
[Required, MinLength(1)] public List<UomConversionInput> Conversions { get; set; } = new();
}
@@ -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(
long PoLineId, long ItemId, long UomId, long 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(
long PoId, string DocNo, long VendorId, long? RequisitionId, PurchaseOrderStatus Status,
bool ApprovalRequired, long CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
public sealed record PurchaseOrderSummaryDto(
long PoId, string DocNo, long VendorId, PurchaseOrderStatus Status,
bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals);
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
public sealed class CreatePoLineInput
{
[Required] public long ItemId { get; set; }
[Required] public long UomId { get; set; }
[Required] public long 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 long VendorId { get; set; }
public long? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
}
public sealed class UpdatePurchaseOrderRequest
{
[Required] public long VendorId { get; set; }
public long? 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(long ReturnLineId, long? GrnLineId, long ItemId, decimal Qty);
public sealed record PurchaseReturnDto(
long ReturnId, string DocNo, long VendorId, long WarehouseId, long ReasonCodeId, ReturnStatus Status,
long CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreatePurchaseReturnLineInput
{
/// <summary>Original GRN line, for traceability against the receipt.</summary>
public long? GrnLineId { get; set; }
[Required] public long ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreatePurchaseReturnRequest
{
[Required] public long VendorId { get; set; }
[Required] public long WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public long? 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(long ReqLineId, long ItemId, decimal Qty, DateOnly? RequiredBy);
public sealed record RequisitionDto(
long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy,
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
public sealed record RequisitionSummaryDto(
long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy, DateTime CreatedAt);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
public sealed class CreateRequisitionLineInput
{
[Required] public long 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(long RfqLineId, long ItemId, decimal Qty);
public sealed record RfqDto(
long RfqId, string DocNo, long RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
public sealed record QuotationLineDto(long ItemId, decimal UnitPrice, int LeadDays);
public sealed record VendorQuotationDto(
long QuotationId, long RfqId, long VendorId, IReadOnlyList<QuotationLineDto> Lines);
/// <summary>Per-item, per-vendor price matrix for <c>GET /rfqs/{id}/comparison</c>.</summary>
public sealed record RfqComparisonCellDto(long VendorId, long QuotationId, decimal UnitPrice, int LeadDays);
public sealed record RfqComparisonRowDto(long ItemId, decimal Qty, IReadOnlyList<RfqComparisonCellDto> Quotes);
public sealed record RfqComparisonDto(long RfqId, IReadOnlyList<long> VendorIds, IReadOnlyList<RfqComparisonRowDto> Rows);
// Requests ----------------------------------------------------------------------
public sealed class CreateRfqLineInput
{
[Required] public long ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateRfqRequest
{
[Required] public long RequisitionId { get; set; }
/// <summary>Vendors the RFQ is issued to (validated for existence; quotations reference them).</summary>
public List<long> VendorIds { get; set; } = new();
[Required, MinLength(1)] public List<CreateRfqLineInput> Lines { get; set; } = new();
}
public sealed class CreateQuotationLineInput
{
[Required] public long 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 long 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(long 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(long AdjLineId, long ItemId, long? BinId, long? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
long AdjustmentId, string DocNo, long WarehouseId, long ReasonCodeId, AdjustmentStatus Status,
long CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateAdjustmentLineInput
{
[Required] public long ItemId { get; set; }
public long? BinId { get; set; }
public long? 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 long WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
public long? 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(long CountLineId, long ItemId, long? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
long CountId, string DocNo, long WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
public sealed record CountPostResultDto(long CountId, CountStatus Status, long? AdjustmentId, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateCountRequest
{
[Required] public long WarehouseId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<long> ItemIds { get; set; } = new();
}
public sealed class EnterCountLineInput
{
[Required] public long 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(
long ItemId, long 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(
long ItemId, long 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(
long LedgerId, long ItemId, long WarehouseId, long? BinId, long? BatchId, long? SerialId,
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
string SourceDocType, long SourceDocId, long UserId, DateTime CreatedAt);
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
public sealed record StockValuationLayerDto(long 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(
long ItemId, long 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(
long TransferLineId, long ItemId, long? SrcBinId, long? DestBinId, long? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
long TransferId, string DocNo, long SrcWarehouseId, long DestWarehouseId,
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
public sealed record ConsumedLayerDto(long LayerId, decimal QtyConsumed, decimal UnitCost);
public sealed record DispatchResultDto(
long TransferId, TransferStatus Status, IReadOnlyList<ConsumedLayerDto> ConsumedLayers, IReadOnlyList<long> LedgerRefs);
public sealed record TransferCreatedLayerDto(long LayerId, long WarehouseId, decimal QtyReceived, decimal UnitCost);
public sealed record ReceiveResultDto(
long TransferId, TransferStatus Status, IReadOnlyList<TransferCreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateTransferLineInput
{
[Required] public long ItemId { get; set; }
public long? SrcBinId { get; set; }
public long? DestBinId { get; set; }
public long? BatchId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateTransferRequest
{
[Required] public long SrcWarehouseId { get; set; }
[Required] public long DestWarehouseId { get; set; }
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
}
public sealed class ReceiveTransferLineInput
{
[Required] public long 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();
}
+11
View File
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(long UomId, string Name);
public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto(
long VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
}
public sealed class UpdateVendorRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
}
public sealed class UpdateVendorStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses;
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public sealed record WarehouseDto(long WarehouseId, string Code, string Name);
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(long BinId, long WarehouseId, string Code, string? BinType);
public sealed class CreateWarehouseRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class CreateBinRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[StringLength(50)] public string? BinType { get; set; }
}
@@ -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 ERPCore.Domain.Entities;
namespace ERPCore.Infra.Auth;
@@ -28,4 +29,6 @@ public sealed class CurrentUser : ICurrentUser
return string.IsNullOrWhiteSpace(sub) ? SystemActor : sub;
}
}
public long AuditUserId => long.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>
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>
long AuditUserId { get; }
/// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; }
}
@@ -1,25 +1,42 @@
using System.Text;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace ERPCore.Infra.Auth;
/// <summary>
/// JWT bearer wiring. Authentication only — RBAC/authorization policies are
/// deferred for Phase 1; the validated principal exists solely so that
/// <see cref="ICurrentUser"/> can stamp the audit actor.
/// Auth wiring for ERPCore as a **resource server** for the external AuthHex IdP
/// (docs/10 A.4). Validates AuthHex's **RS256** tokens against AuthHex's RSA public
/// 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>
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)
{
var issuer = config["Jwt:Issuer"];
var audience = config["Jwt:Audience"];
var signingKey = config["Jwt:SigningKey"] ?? string.Empty;
var issuer = config["Auth:Issuer"];
var audience = config["Auth:Audience"];
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)
.AddJwtBearer(options =>
{
// Keep AuthHex's claim names verbatim (UserId, UserTypeCode, RoleCode …).
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
@@ -28,12 +45,26 @@ public static class JwtAuthExtensions
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
IssuerSigningKey = signingKey,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
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;
}
}
@@ -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>long</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<long> 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, long 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, long 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 long 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.ToInt64(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,25 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BinConfiguration : IEntityTypeConfiguration<Bin>
{
public void Configure(EntityTypeBuilder<Bin> builder)
{
builder.ToTable("bins");
builder.HasKey(b => b.BinId);
builder.Property(b => b.Code).IsRequired().HasMaxLength(50);
builder.Property(b => b.BinType).HasMaxLength(50);
builder.HasOne(b => b.Warehouse)
.WithMany(w => w.Bins)
.HasForeignKey(b => b.WarehouseId)
.OnDelete(DeleteBehavior.Cascade);
// Bin code unique within its warehouse.
builder.HasIndex(b => new { b.WarehouseId, b.Code }).IsUnique();
}
}

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