Compare commits

..

5 Commits

Author SHA1 Message Date
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
96 changed files with 6953 additions and 186 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,28 @@
using ERPCore.Common.Http;
using ERPCore.System.Errors;
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).
/// </summary>
[ApiController]
[Produces("application/json")]
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,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,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,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,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,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";
}
+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>();
}
+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,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,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; }
}
+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; }
}
+20
View File
@@ -0,0 +1,20 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Application user (FR-X-01). In Phase 1 authentication/RBAC are deferred; this
/// table exists so mutations can be stamped with an audit actor and documents can
/// carry a `createdBy`/`requestedBy` FK. A seeded <c>system</c> user (id 1) is the
/// fallback actor until `/auth/login` lands (§6). Model: docs/10 Part C.7.
/// </summary>
public class User
{
/// <summary>Seeded fallback actor used while auth is deferred.</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;
}
+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,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
}
+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,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>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,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);
+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,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();
}
+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; }
}
@@ -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; }
}
@@ -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();
}
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.ToTable("categories");
builder.HasKey(c => c.CategoryId);
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.ParentId);
}
}
@@ -0,0 +1,53 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
{
public void Configure(EntityTypeBuilder<Item> builder)
{
builder.ToTable("items");
builder.HasKey(i => i.ItemId);
builder.Property(i => i.Sku).IsRequired().HasMaxLength(50);
builder.HasIndex(i => i.Sku).IsUnique();
builder.Property(i => i.Name).IsRequired().HasMaxLength(200);
builder.Property(i => i.Description).HasMaxLength(1000);
builder.Property(i => i.TaxClass).HasMaxLength(20);
builder.Property(i => i.ItemType)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(i => i.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasOne(i => i.Category)
.WithMany()
.HasForeignKey(i => i.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.BaseUom)
.WithMany()
.HasForeignKey(i => i.BaseUomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.DefaultVendor)
.WithMany()
.HasForeignKey(i => i.DefaultVendorId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(i => i.Status);
builder.HasIndex(i => i.CategoryId);
}
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class ItemReorderConfiguration : IEntityTypeConfiguration<ItemReorder>
{
public void Configure(EntityTypeBuilder<ItemReorder> builder)
{
builder.ToTable("item_reorders");
builder.HasKey(r => r.ReorderId);
builder.Property(r => r.ReorderPoint).HasPrecision(18, 4);
builder.Property(r => r.ReorderQty).HasPrecision(18, 4);
builder.HasOne(r => r.Item)
.WithMany(i => i.ReorderSettings)
.HasForeignKey(r => r.ItemId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(r => r.Warehouse)
.WithMany()
.HasForeignKey(r => r.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
// One reorder policy per (item, warehouse).
builder.HasIndex(r => new { r.ItemId, r.WarehouseId }).IsUnique();
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NumberSequenceConfiguration : IEntityTypeConfiguration<NumberSequence>
{
public void Configure(EntityTypeBuilder<NumberSequence> builder)
{
builder.ToTable("number_sequences");
builder.HasKey(s => s.SequenceId);
builder.Property(s => s.DocType).HasColumnName("doc_type").IsRequired().HasMaxLength(10);
builder.Property(s => s.Year).HasColumnName("year").IsRequired();
builder.Property(s => s.LastNumber).HasColumnName("last_number").IsRequired();
// One counter per (doc type, year); also the ON CONFLICT target for atomic issue.
builder.HasIndex(s => new { s.DocType, s.Year }).IsUnique();
}
}
@@ -0,0 +1,76 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration<PurchaseOrder>
{
public void Configure(EntityTypeBuilder<PurchaseOrder> builder)
{
builder.ToTable("purchase_orders");
builder.HasKey(p => p.PoId);
builder.Property(p => p.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(p => p.DocNo).IsUnique();
builder.Property(p => p.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(p => p.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(p => p.RowVersion).IsRowVersion();
builder.HasOne(p => p.Vendor)
.WithMany()
.HasForeignKey(p => p.VendorId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Requisition)
.WithMany()
.HasForeignKey(p => p.RequisitionId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator)
.WithMany()
.HasForeignKey(p => p.CreatedBy)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.Status);
builder.HasIndex(p => p.VendorId);
}
}
public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
{
public void Configure(EntityTypeBuilder<PoLine> builder)
{
builder.ToTable("po_lines");
builder.HasKey(l => l.PoLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.Property(l => l.Tax).HasPrecision(9, 4);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
builder.HasOne(l => l.PurchaseOrder)
.WithMany(p => p.Lines)
.HasForeignKey(l => l.PoId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom)
.WithMany()
.HasForeignKey(l => l.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Warehouse)
.WithMany()
.HasForeignKey(l => l.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,49 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RequisitionConfiguration : IEntityTypeConfiguration<Requisition>
{
public void Configure(EntityTypeBuilder<Requisition> builder)
{
builder.ToTable("requisitions");
builder.HasKey(r => r.RequisitionId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Requester)
.WithMany()
.HasForeignKey(r => r.RequestedBy)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(r => r.Status);
}
}
public sealed class RequisitionLineConfiguration : IEntityTypeConfiguration<RequisitionLine>
{
public void Configure(EntityTypeBuilder<RequisitionLine> builder)
{
builder.ToTable("requisition_lines");
builder.HasKey(l => l.ReqLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Requisition)
.WithMany(r => r.Lines)
.HasForeignKey(l => l.RequisitionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,92 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RfqConfiguration : IEntityTypeConfiguration<Rfq>
{
public void Configure(EntityTypeBuilder<Rfq> builder)
{
builder.ToTable("rfqs");
builder.HasKey(r => r.RfqId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Requisition)
.WithMany()
.HasForeignKey(r => r.RequisitionId)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class RfqLineConfiguration : IEntityTypeConfiguration<RfqLine>
{
public void Configure(EntityTypeBuilder<RfqLine> builder)
{
builder.ToTable("rfq_lines");
builder.HasKey(l => l.RfqLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Rfq)
.WithMany(r => r.Lines)
.HasForeignKey(l => l.RfqId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class VendorQuotationConfiguration : IEntityTypeConfiguration<VendorQuotation>
{
public void Configure(EntityTypeBuilder<VendorQuotation> builder)
{
builder.ToTable("vendor_quotations");
builder.HasKey(q => q.QuotationId);
builder.Property(q => q.CreatedAt).IsRequired();
builder.HasOne(q => q.Rfq)
.WithMany(r => r.Quotations)
.HasForeignKey(q => q.RfqId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(q => q.Vendor)
.WithMany()
.HasForeignKey(q => q.VendorId)
.OnDelete(DeleteBehavior.Restrict);
// One quotation per vendor per RFQ.
builder.HasIndex(q => new { q.RfqId, q.VendorId }).IsUnique();
}
}
public sealed class VendorQuotationLineConfiguration : IEntityTypeConfiguration<VendorQuotationLine>
{
public void Configure(EntityTypeBuilder<VendorQuotationLine> builder)
{
builder.ToTable("vendor_quotation_lines");
builder.HasKey(l => l.QuotationLineId);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.HasOne(l => l.Quotation)
.WithMany(q => q.Lines)
.HasForeignKey(l => l.QuotationId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item)
.WithMany()
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,17 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
{
public void Configure(EntityTypeBuilder<Uom> builder)
{
builder.ToTable("uoms");
builder.HasKey(u => u.UomId);
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique();
}
}
@@ -0,0 +1,34 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomConversion>
{
public void Configure(EntityTypeBuilder<UomConversion> builder)
{
builder.ToTable("uom_conversions");
builder.HasKey(c => c.ConversionId);
builder.Property(c => c.Factor).HasPrecision(18, 6);
builder.HasOne(c => c.Item)
.WithMany(i => i.UomConversions)
.HasForeignKey(c => c.ItemId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.FromUom)
.WithMany()
.HasForeignKey(c => c.FromUomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.ToUom)
.WithMany()
.HasForeignKey(c => c.ToUomId)
.OnDelete(DeleteBehavior.Restrict);
// One conversion per (item, from, to) triple.
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
}
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users");
builder.HasKey(u => u.UserId);
builder.Property(u => u.Username).IsRequired().HasMaxLength(100);
builder.HasIndex(u => u.Username).IsUnique();
builder.Property(u => u.DisplayName).IsRequired().HasMaxLength(200);
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
// Seeded fallback audit actor while auth is deferred (§6).
builder.HasData(new User
{
UserId = User.SystemUserId,
Username = "system",
DisplayName = "System",
Status = EntityStatus.Active
});
}
}
@@ -0,0 +1,34 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
{
public void Configure(EntityTypeBuilder<Vendor> builder)
{
builder.ToTable("vendors");
builder.HasKey(v => v.VendorId);
builder.Property(v => v.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(v => v.Code).IsUnique();
builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
builder.Property(v => v.Terms).HasMaxLength(50);
builder.Property(v => v.TaxReg).HasMaxLength(50);
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
builder.Property(v => v.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(v => v.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(v => v.RowVersion).IsRowVersion();
builder.HasIndex(v => v.Status);
}
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
{
public void Configure(EntityTypeBuilder<Warehouse> builder)
{
builder.ToTable("warehouses");
builder.HasKey(w => w.WarehouseId);
builder.Property(w => w.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200);
}
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
@@ -14,6 +15,30 @@ public class ErpDbContext : DbContext
{
}
// --- Master Data (docs/10 Part C.1) ---
public DbSet<Category> Categories => Set<Category>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>();
public DbSet<NumberSequence> NumberSequences => Set<NumberSequence>();
// --- Procurement (docs/10 Part C.2) ---
public DbSet<Requisition> Requisitions => Set<Requisition>();
public DbSet<RequisitionLine> RequisitionLines => Set<RequisitionLine>();
public DbSet<Rfq> Rfqs => Set<Rfq>();
public DbSet<RfqLine> RfqLines => Set<RfqLine>();
public DbSet<VendorQuotation> VendorQuotations => Set<VendorQuotation>();
public DbSet<VendorQuotationLine> VendorQuotationLines => Set<VendorQuotationLine>();
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
public DbSet<PoLine> PoLines => Set<PoLine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -0,0 +1,445 @@
// <auto-generated />
using System;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260709095653_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.Property<long>("BinId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("BinId"));
b.Property<string>("BinType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("BinId");
b.HasIndex("WarehouseId", "Code")
.IsUnique();
b.ToTable("bins", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<long>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("CategoryId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("ParentId")
.HasColumnType("bigint");
b.HasKey("CategoryId");
b.HasIndex("ParentId");
b.ToTable("categories", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Property<long>("ItemId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ItemId"));
b.Property<long>("BaseUomId")
.HasColumnType("bigint");
b.Property<long>("CategoryId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("DefaultVendorId")
.HasColumnType("bigint");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Sku")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TrackingMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ItemId");
b.HasIndex("BaseUomId");
b.HasIndex("CategoryId");
b.HasIndex("DefaultVendorId");
b.HasIndex("Sku")
.IsUnique();
b.HasIndex("Status");
b.ToTable("items", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<long>("ReorderId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ReorderId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("ReorderPoint")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReorderQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("ReorderId");
b.HasIndex("WarehouseId");
b.HasIndex("ItemId", "WarehouseId")
.IsUnique();
b.ToTable("item_reorders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{
b.Property<long>("UomId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("UomId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("uoms", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.Property<long>("ConversionId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ConversionId"));
b.Property<decimal>("Factor")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<long>("FromUomId")
.HasColumnType("bigint");
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<long>("ToUomId")
.HasColumnType("bigint");
b.HasKey("ConversionId");
b.HasIndex("FromUomId");
b.HasIndex("ToUomId");
b.HasIndex("ItemId", "FromUomId", "ToUomId")
.IsUnique();
b.ToTable("uom_conversions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b =>
{
b.Property<long>("VendorId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("VendorId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasDefaultValue("LKR");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxReg")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Terms")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("VendorId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Status");
b.ToTable("vendors", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Property<long>("WarehouseId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("WarehouseId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("WarehouseId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany("Bins")
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
.WithMany()
.HasForeignKey("BaseUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor")
.WithMany()
.HasForeignKey("DefaultVendorId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("BaseUom");
b.Navigation("Category");
b.Navigation("DefaultVendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("ReorderSettings")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
.WithMany()
.HasForeignKey("FromUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("UomConversions")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom")
.WithMany()
.HasForeignKey("ToUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromUom");
b.Navigation("Item");
b.Navigation("ToUom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Navigation("ReorderSettings");
b.Navigation("UomConversions");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Navigation("Bins");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,325 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "categories",
columns: table => new
{
CategoryId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ParentId = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_categories", x => x.CategoryId);
table.ForeignKey(
name: "FK_categories_categories_ParentId",
column: x => x.ParentId,
principalTable: "categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "uoms",
columns: table => new
{
UomId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_uoms", x => x.UomId);
});
migrationBuilder.CreateTable(
name: "vendors",
columns: table => new
{
VendorId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Terms = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
TaxReg = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Currency = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false, defaultValue: "LKR"),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_vendors", x => x.VendorId);
});
migrationBuilder.CreateTable(
name: "warehouses",
columns: table => new
{
WarehouseId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_warehouses", x => x.WarehouseId);
});
migrationBuilder.CreateTable(
name: "items",
columns: table => new
{
ItemId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Sku = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
CategoryId = table.Column<long>(type: "bigint", nullable: false),
BaseUomId = table.Column<long>(type: "bigint", nullable: false),
DefaultVendorId = table.Column<long>(type: "bigint", nullable: true),
ItemType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TrackingMode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TaxClass = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_items", x => x.ItemId);
table.ForeignKey(
name: "FK_items_categories_CategoryId",
column: x => x.CategoryId,
principalTable: "categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_items_uoms_BaseUomId",
column: x => x.BaseUomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_items_vendors_DefaultVendorId",
column: x => x.DefaultVendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "bins",
columns: table => new
{
BinId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
BinType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_bins", x => x.BinId);
table.ForeignKey(
name: "FK_bins_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "item_reorders",
columns: table => new
{
ReorderId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<long>(type: "bigint", nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
ReorderPoint = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ReorderQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_item_reorders", x => x.ReorderId);
table.ForeignKey(
name: "FK_item_reorders_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_item_reorders_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "uom_conversions",
columns: table => new
{
ConversionId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<long>(type: "bigint", nullable: false),
FromUomId = table.Column<long>(type: "bigint", nullable: false),
ToUomId = table.Column<long>(type: "bigint", nullable: false),
Factor = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_uom_conversions", x => x.ConversionId);
table.ForeignKey(
name: "FK_uom_conversions_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_uom_conversions_uoms_FromUomId",
column: x => x.FromUomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_uom_conversions_uoms_ToUomId",
column: x => x.ToUomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_bins_WarehouseId_Code",
table: "bins",
columns: new[] { "WarehouseId", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_categories_ParentId",
table: "categories",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_item_reorders_ItemId_WarehouseId",
table: "item_reorders",
columns: new[] { "ItemId", "WarehouseId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_item_reorders_WarehouseId",
table: "item_reorders",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_items_BaseUomId",
table: "items",
column: "BaseUomId");
migrationBuilder.CreateIndex(
name: "IX_items_CategoryId",
table: "items",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_items_DefaultVendorId",
table: "items",
column: "DefaultVendorId");
migrationBuilder.CreateIndex(
name: "IX_items_Sku",
table: "items",
column: "Sku",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_items_Status",
table: "items",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_uom_conversions_FromUomId",
table: "uom_conversions",
column: "FromUomId");
migrationBuilder.CreateIndex(
name: "IX_uom_conversions_ItemId_FromUomId_ToUomId",
table: "uom_conversions",
columns: new[] { "ItemId", "FromUomId", "ToUomId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_uom_conversions_ToUomId",
table: "uom_conversions",
column: "ToUomId");
migrationBuilder.CreateIndex(
name: "IX_uoms_Name",
table: "uoms",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_vendors_Code",
table: "vendors",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_vendors_Status",
table: "vendors",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_warehouses_Code",
table: "warehouses",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "bins");
migrationBuilder.DropTable(
name: "item_reorders");
migrationBuilder.DropTable(
name: "uom_conversions");
migrationBuilder.DropTable(
name: "warehouses");
migrationBuilder.DropTable(
name: "items");
migrationBuilder.DropTable(
name: "categories");
migrationBuilder.DropTable(
name: "uoms");
migrationBuilder.DropTable(
name: "vendors");
}
}
}
@@ -0,0 +1,445 @@
// <auto-generated />
using System;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260709124415_initial")]
partial class initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.Property<long>("BinId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("BinId"));
b.Property<string>("BinType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("BinId");
b.HasIndex("WarehouseId", "Code")
.IsUnique();
b.ToTable("bins", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<long>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("CategoryId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("ParentId")
.HasColumnType("bigint");
b.HasKey("CategoryId");
b.HasIndex("ParentId");
b.ToTable("categories", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Property<long>("ItemId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ItemId"));
b.Property<long>("BaseUomId")
.HasColumnType("bigint");
b.Property<long>("CategoryId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("DefaultVendorId")
.HasColumnType("bigint");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Sku")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TrackingMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ItemId");
b.HasIndex("BaseUomId");
b.HasIndex("CategoryId");
b.HasIndex("DefaultVendorId");
b.HasIndex("Sku")
.IsUnique();
b.HasIndex("Status");
b.ToTable("items", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<long>("ReorderId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ReorderId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("ReorderPoint")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReorderQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("ReorderId");
b.HasIndex("WarehouseId");
b.HasIndex("ItemId", "WarehouseId")
.IsUnique();
b.ToTable("item_reorders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{
b.Property<long>("UomId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("UomId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("uoms", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.Property<long>("ConversionId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ConversionId"));
b.Property<decimal>("Factor")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<long>("FromUomId")
.HasColumnType("bigint");
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<long>("ToUomId")
.HasColumnType("bigint");
b.HasKey("ConversionId");
b.HasIndex("FromUomId");
b.HasIndex("ToUomId");
b.HasIndex("ItemId", "FromUomId", "ToUomId")
.IsUnique();
b.ToTable("uom_conversions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b =>
{
b.Property<long>("VendorId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("VendorId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasDefaultValue("LKR");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxReg")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Terms")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("VendorId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Status");
b.ToTable("vendors", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Property<long>("WarehouseId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("WarehouseId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("WarehouseId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany("Bins")
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
.WithMany()
.HasForeignKey("BaseUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor")
.WithMany()
.HasForeignKey("DefaultVendorId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("BaseUom");
b.Navigation("Category");
b.Navigation("DefaultVendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("ReorderSettings")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
.WithMany()
.HasForeignKey("FromUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("UomConversions")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom")
.WithMany()
.HasForeignKey("ToUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromUom");
b.Navigation("Item");
b.Navigation("ToUom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Navigation("ReorderSettings");
b.Navigation("UomConversions");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Navigation("Bins");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,987 @@
// <auto-generated />
using System;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260710090753_AddProcurement")]
partial class AddProcurement
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.Property<long>("BinId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("BinId"));
b.Property<string>("BinType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("BinId");
b.HasIndex("WarehouseId", "Code")
.IsUnique();
b.ToTable("bins", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<long>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("CategoryId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("ParentId")
.HasColumnType("bigint");
b.HasKey("CategoryId");
b.HasIndex("ParentId");
b.ToTable("categories", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Property<long>("ItemId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ItemId"));
b.Property<long>("BaseUomId")
.HasColumnType("bigint");
b.Property<long>("CategoryId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("DefaultVendorId")
.HasColumnType("bigint");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Sku")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TrackingMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ItemId");
b.HasIndex("BaseUomId");
b.HasIndex("CategoryId");
b.HasIndex("DefaultVendorId");
b.HasIndex("Sku")
.IsUnique();
b.HasIndex("Status");
b.ToTable("items", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<long>("ReorderId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ReorderId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("ReorderPoint")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReorderQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("ReorderId");
b.HasIndex("WarehouseId");
b.HasIndex("ItemId", "WarehouseId")
.IsUnique();
b.ToTable("item_reorders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
{
b.Property<long>("SequenceId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceId"));
b.Property<string>("DocType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("doc_type");
b.Property<long>("LastNumber")
.HasColumnType("bigint")
.HasColumnName("last_number");
b.Property<int>("Year")
.HasColumnType("integer")
.HasColumnName("year");
b.HasKey("SequenceId");
b.HasIndex("DocType", "Year")
.IsUnique();
b.ToTable("number_sequences", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
{
b.Property<long>("PoLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("PoLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<long>("PoId")
.HasColumnType("bigint");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("QtyReceived")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("Tax")
.HasPrecision(9, 4)
.HasColumnType("numeric(9,4)");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("UomId")
.HasColumnType("bigint");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("PoLineId");
b.HasIndex("ItemId");
b.HasIndex("PoId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("po_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Property<long>("PoId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("PoId"));
b.Property<bool>("ApprovalRequired")
.HasColumnType("boolean");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("CreatedBy")
.HasColumnType("bigint");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<long?>("RequisitionId")
.HasColumnType("bigint");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("VendorId")
.HasColumnType("bigint");
b.HasKey("PoId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("RequisitionId");
b.HasIndex("Status");
b.HasIndex("VendorId");
b.ToTable("purchase_orders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b =>
{
b.Property<long>("RequisitionId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("RequisitionId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<long>("RequestedBy")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("RequisitionId");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("RequestedBy");
b.HasIndex("Status");
b.ToTable("requisitions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b =>
{
b.Property<long>("ReqLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ReqLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateOnly?>("RequiredBy")
.HasColumnType("date");
b.Property<long>("RequisitionId")
.HasColumnType("bigint");
b.HasKey("ReqLineId");
b.HasIndex("ItemId");
b.HasIndex("RequisitionId");
b.ToTable("requisition_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b =>
{
b.Property<long>("RfqId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("RfqId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<long>("RequisitionId")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("RfqId");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("RequisitionId");
b.ToTable("rfqs", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b =>
{
b.Property<long>("RfqLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("RfqLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("RfqId")
.HasColumnType("bigint");
b.HasKey("RfqLineId");
b.HasIndex("ItemId");
b.HasIndex("RfqId");
b.ToTable("rfq_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{
b.Property<long>("UomId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("UomId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("uoms", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.Property<long>("ConversionId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ConversionId"));
b.Property<decimal>("Factor")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<long>("FromUomId")
.HasColumnType("bigint");
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<long>("ToUomId")
.HasColumnType("bigint");
b.HasKey("ConversionId");
b.HasIndex("FromUomId");
b.HasIndex("ToUomId");
b.HasIndex("ItemId", "FromUomId", "ToUomId")
.IsUnique();
b.ToTable("uom_conversions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
{
b.Property<long>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("UserId"));
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("UserId");
b.HasIndex("Username")
.IsUnique();
b.ToTable("users", (string)null);
b.HasData(
new
{
UserId = 1L,
DisplayName = "System",
Status = "Active",
Username = "system"
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b =>
{
b.Property<long>("VendorId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("VendorId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasDefaultValue("LKR");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxReg")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Terms")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("VendorId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Status");
b.ToTable("vendors", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.Property<long>("QuotationId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("QuotationId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("RfqId")
.HasColumnType("bigint");
b.Property<long>("VendorId")
.HasColumnType("bigint");
b.HasKey("QuotationId");
b.HasIndex("VendorId");
b.HasIndex("RfqId", "VendorId")
.IsUnique();
b.ToTable("vendor_quotations", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b =>
{
b.Property<long>("QuotationLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("QuotationLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<int>("LeadDays")
.HasColumnType("integer");
b.Property<long>("QuotationId")
.HasColumnType("bigint");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.HasKey("QuotationLineId");
b.HasIndex("ItemId");
b.HasIndex("QuotationId");
b.ToTable("vendor_quotation_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Property<long>("WarehouseId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("WarehouseId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("WarehouseId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany("Bins")
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
.WithMany()
.HasForeignKey("BaseUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor")
.WithMany()
.HasForeignKey("DefaultVendorId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("BaseUom");
b.Navigation("Category");
b.Navigation("DefaultVendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("ReorderSettings")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder")
.WithMany("Lines")
.HasForeignKey("PoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("PurchaseOrder");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition")
.WithMany()
.HasForeignKey("RequisitionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Requisition");
b.Navigation("Vendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Requester")
.WithMany()
.HasForeignKey("RequestedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Requester");
});
modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition")
.WithMany("Lines")
.HasForeignKey("RequisitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Requisition");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b =>
{
b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition")
.WithMany()
.HasForeignKey("RequisitionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Requisition");
});
modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
.WithMany("Lines")
.HasForeignKey("RfqId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Rfq");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
.WithMany()
.HasForeignKey("FromUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("UomConversions")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom")
.WithMany()
.HasForeignKey("ToUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromUom");
b.Navigation("Item");
b.Navigation("ToUom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
.WithMany("Quotations")
.HasForeignKey("RfqId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Rfq");
b.Navigation("Vendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation")
.WithMany("Lines")
.HasForeignKey("QuotationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Navigation("ReorderSettings");
b.Navigation("UomConversions");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b =>
{
b.Navigation("Lines");
b.Navigation("Quotations");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Navigation("Bins");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,448 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddProcurement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "number_sequences",
columns: table => new
{
SequenceId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
doc_type = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
year = table.Column<int>(type: "integer", nullable: false),
last_number = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_number_sequences", x => x.SequenceId);
});
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
UserId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.UserId);
});
migrationBuilder.CreateTable(
name: "requisitions",
columns: table => new
{
RequisitionId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
RequestedBy = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_requisitions", x => x.RequisitionId);
table.ForeignKey(
name: "FK_requisitions_users_RequestedBy",
column: x => x.RequestedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "purchase_orders",
columns: table => new
{
PoId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
VendorId = table.Column<long>(type: "bigint", nullable: false),
RequisitionId = table.Column<long>(type: "bigint", nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ApprovalRequired = table.Column<bool>(type: "boolean", nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_orders", x => x.PoId);
table.ForeignKey(
name: "FK_purchase_orders_requisitions_RequisitionId",
column: x => x.RequisitionId,
principalTable: "requisitions",
principalColumn: "RequisitionId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_orders_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_orders_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "requisition_lines",
columns: table => new
{
ReqLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RequisitionId = table.Column<long>(type: "bigint", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
RequiredBy = table.Column<DateOnly>(type: "date", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_requisition_lines", x => x.ReqLineId);
table.ForeignKey(
name: "FK_requisition_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_requisition_lines_requisitions_RequisitionId",
column: x => x.RequisitionId,
principalTable: "requisitions",
principalColumn: "RequisitionId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "rfqs",
columns: table => new
{
RfqId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
RequisitionId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_rfqs", x => x.RfqId);
table.ForeignKey(
name: "FK_rfqs_requisitions_RequisitionId",
column: x => x.RequisitionId,
principalTable: "requisitions",
principalColumn: "RequisitionId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "po_lines",
columns: table => new
{
PoLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PoId = table.Column<long>(type: "bigint", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: false),
UomId = table.Column<long>(type: "bigint", nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
Tax = table.Column<decimal>(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false),
QtyReceived = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_po_lines", x => x.PoLineId);
table.ForeignKey(
name: "FK_po_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_po_lines_purchase_orders_PoId",
column: x => x.PoId,
principalTable: "purchase_orders",
principalColumn: "PoId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_po_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_po_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "rfq_lines",
columns: table => new
{
RfqLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RfqId = table.Column<long>(type: "bigint", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_rfq_lines", x => x.RfqLineId);
table.ForeignKey(
name: "FK_rfq_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_rfq_lines_rfqs_RfqId",
column: x => x.RfqId,
principalTable: "rfqs",
principalColumn: "RfqId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "vendor_quotations",
columns: table => new
{
QuotationId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RfqId = table.Column<long>(type: "bigint", nullable: false),
VendorId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_vendor_quotations", x => x.QuotationId);
table.ForeignKey(
name: "FK_vendor_quotations_rfqs_RfqId",
column: x => x.RfqId,
principalTable: "rfqs",
principalColumn: "RfqId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_vendor_quotations_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "vendor_quotation_lines",
columns: table => new
{
QuotationLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
QuotationId = table.Column<long>(type: "bigint", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LeadDays = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_vendor_quotation_lines", x => x.QuotationLineId);
table.ForeignKey(
name: "FK_vendor_quotation_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_vendor_quotation_lines_vendor_quotations_QuotationId",
column: x => x.QuotationId,
principalTable: "vendor_quotations",
principalColumn: "QuotationId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "users",
columns: new[] { "UserId", "DisplayName", "Status", "Username" },
values: new object[] { 1L, "System", "Active", "system" });
migrationBuilder.CreateIndex(
name: "IX_number_sequences_doc_type_year",
table: "number_sequences",
columns: new[] { "doc_type", "year" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_po_lines_ItemId",
table: "po_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_po_lines_PoId",
table: "po_lines",
column: "PoId");
migrationBuilder.CreateIndex(
name: "IX_po_lines_UomId",
table: "po_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_po_lines_WarehouseId",
table: "po_lines",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_purchase_orders_CreatedBy",
table: "purchase_orders",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_orders_DocNo",
table: "purchase_orders",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_orders_RequisitionId",
table: "purchase_orders",
column: "RequisitionId");
migrationBuilder.CreateIndex(
name: "IX_purchase_orders_Status",
table: "purchase_orders",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_purchase_orders_VendorId",
table: "purchase_orders",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_requisition_lines_ItemId",
table: "requisition_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_requisition_lines_RequisitionId",
table: "requisition_lines",
column: "RequisitionId");
migrationBuilder.CreateIndex(
name: "IX_requisitions_DocNo",
table: "requisitions",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_requisitions_RequestedBy",
table: "requisitions",
column: "RequestedBy");
migrationBuilder.CreateIndex(
name: "IX_requisitions_Status",
table: "requisitions",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_rfq_lines_ItemId",
table: "rfq_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_rfq_lines_RfqId",
table: "rfq_lines",
column: "RfqId");
migrationBuilder.CreateIndex(
name: "IX_rfqs_DocNo",
table: "rfqs",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_rfqs_RequisitionId",
table: "rfqs",
column: "RequisitionId");
migrationBuilder.CreateIndex(
name: "IX_users_Username",
table: "users",
column: "Username",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_vendor_quotation_lines_ItemId",
table: "vendor_quotation_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_vendor_quotation_lines_QuotationId",
table: "vendor_quotation_lines",
column: "QuotationId");
migrationBuilder.CreateIndex(
name: "IX_vendor_quotations_RfqId_VendorId",
table: "vendor_quotations",
columns: new[] { "RfqId", "VendorId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_vendor_quotations_VendorId",
table: "vendor_quotations",
column: "VendorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "number_sequences");
migrationBuilder.DropTable(
name: "po_lines");
migrationBuilder.DropTable(
name: "requisition_lines");
migrationBuilder.DropTable(
name: "rfq_lines");
migrationBuilder.DropTable(
name: "vendor_quotation_lines");
migrationBuilder.DropTable(
name: "purchase_orders");
migrationBuilder.DropTable(
name: "vendor_quotations");
migrationBuilder.DropTable(
name: "rfqs");
migrationBuilder.DropTable(
name: "requisitions");
migrationBuilder.DropTable(
name: "users");
}
}
}
@@ -0,0 +1,984 @@
// <auto-generated />
using System;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
[DbContext(typeof(ErpDbContext))]
partial class ErpDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.Property<long>("BinId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("BinId"));
b.Property<string>("BinType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("BinId");
b.HasIndex("WarehouseId", "Code")
.IsUnique();
b.ToTable("bins", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<long>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("CategoryId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("ParentId")
.HasColumnType("bigint");
b.HasKey("CategoryId");
b.HasIndex("ParentId");
b.ToTable("categories", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Property<long>("ItemId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ItemId"));
b.Property<long>("BaseUomId")
.HasColumnType("bigint");
b.Property<long>("CategoryId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("DefaultVendorId")
.HasColumnType("bigint");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Sku")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TrackingMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ItemId");
b.HasIndex("BaseUomId");
b.HasIndex("CategoryId");
b.HasIndex("DefaultVendorId");
b.HasIndex("Sku")
.IsUnique();
b.HasIndex("Status");
b.ToTable("items", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<long>("ReorderId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ReorderId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("ReorderPoint")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReorderQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("ReorderId");
b.HasIndex("WarehouseId");
b.HasIndex("ItemId", "WarehouseId")
.IsUnique();
b.ToTable("item_reorders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
{
b.Property<long>("SequenceId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceId"));
b.Property<string>("DocType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("doc_type");
b.Property<long>("LastNumber")
.HasColumnType("bigint")
.HasColumnName("last_number");
b.Property<int>("Year")
.HasColumnType("integer")
.HasColumnName("year");
b.HasKey("SequenceId");
b.HasIndex("DocType", "Year")
.IsUnique();
b.ToTable("number_sequences", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
{
b.Property<long>("PoLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("PoLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<long>("PoId")
.HasColumnType("bigint");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("QtyReceived")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("Tax")
.HasPrecision(9, 4)
.HasColumnType("numeric(9,4)");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("UomId")
.HasColumnType("bigint");
b.Property<long>("WarehouseId")
.HasColumnType("bigint");
b.HasKey("PoLineId");
b.HasIndex("ItemId");
b.HasIndex("PoId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("po_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Property<long>("PoId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("PoId"));
b.Property<bool>("ApprovalRequired")
.HasColumnType("boolean");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("CreatedBy")
.HasColumnType("bigint");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<long?>("RequisitionId")
.HasColumnType("bigint");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("VendorId")
.HasColumnType("bigint");
b.HasKey("PoId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("RequisitionId");
b.HasIndex("Status");
b.HasIndex("VendorId");
b.ToTable("purchase_orders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b =>
{
b.Property<long>("RequisitionId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("RequisitionId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<long>("RequestedBy")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("RequisitionId");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("RequestedBy");
b.HasIndex("Status");
b.ToTable("requisitions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b =>
{
b.Property<long>("ReqLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ReqLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateOnly?>("RequiredBy")
.HasColumnType("date");
b.Property<long>("RequisitionId")
.HasColumnType("bigint");
b.HasKey("ReqLineId");
b.HasIndex("ItemId");
b.HasIndex("RequisitionId");
b.ToTable("requisition_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b =>
{
b.Property<long>("RfqId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("RfqId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<long>("RequisitionId")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("RfqId");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("RequisitionId");
b.ToTable("rfqs", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b =>
{
b.Property<long>("RfqLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("RfqLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<long>("RfqId")
.HasColumnType("bigint");
b.HasKey("RfqLineId");
b.HasIndex("ItemId");
b.HasIndex("RfqId");
b.ToTable("rfq_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{
b.Property<long>("UomId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("UomId"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("uoms", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.Property<long>("ConversionId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("ConversionId"));
b.Property<decimal>("Factor")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<long>("FromUomId")
.HasColumnType("bigint");
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<long>("ToUomId")
.HasColumnType("bigint");
b.HasKey("ConversionId");
b.HasIndex("FromUomId");
b.HasIndex("ToUomId");
b.HasIndex("ItemId", "FromUomId", "ToUomId")
.IsUnique();
b.ToTable("uom_conversions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
{
b.Property<long>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("UserId"));
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("UserId");
b.HasIndex("Username")
.IsUnique();
b.ToTable("users", (string)null);
b.HasData(
new
{
UserId = 1L,
DisplayName = "System",
Status = "Active",
Username = "system"
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b =>
{
b.Property<long>("VendorId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("VendorId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasDefaultValue("LKR");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TaxReg")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Terms")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("VendorId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Status");
b.ToTable("vendors", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.Property<long>("QuotationId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("QuotationId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("RfqId")
.HasColumnType("bigint");
b.Property<long>("VendorId")
.HasColumnType("bigint");
b.HasKey("QuotationId");
b.HasIndex("VendorId");
b.HasIndex("RfqId", "VendorId")
.IsUnique();
b.ToTable("vendor_quotations", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b =>
{
b.Property<long>("QuotationLineId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("QuotationLineId"));
b.Property<long>("ItemId")
.HasColumnType("bigint");
b.Property<int>("LeadDays")
.HasColumnType("integer");
b.Property<long>("QuotationId")
.HasColumnType("bigint");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.HasKey("QuotationLineId");
b.HasIndex("ItemId");
b.HasIndex("QuotationId");
b.ToTable("vendor_quotation_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Property<long>("WarehouseId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("WarehouseId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("WarehouseId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany("Bins")
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
.WithMany()
.HasForeignKey("BaseUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor")
.WithMany()
.HasForeignKey("DefaultVendorId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("BaseUom");
b.Navigation("Category");
b.Navigation("DefaultVendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("ReorderSettings")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder")
.WithMany("Lines")
.HasForeignKey("PoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("PurchaseOrder");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition")
.WithMany()
.HasForeignKey("RequisitionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Requisition");
b.Navigation("Vendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Requester")
.WithMany()
.HasForeignKey("RequestedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Requester");
});
modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition")
.WithMany("Lines")
.HasForeignKey("RequisitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Requisition");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b =>
{
b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition")
.WithMany()
.HasForeignKey("RequisitionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Requisition");
});
modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
.WithMany("Lines")
.HasForeignKey("RfqId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Rfq");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
.WithMany()
.HasForeignKey("FromUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("UomConversions")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom")
.WithMany()
.HasForeignKey("ToUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromUom");
b.Navigation("Item");
b.Navigation("ToUom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
.WithMany("Quotations")
.HasForeignKey("RfqId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Rfq");
b.Navigation("Vendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation")
.WithMany("Lines")
.HasForeignKey("QuotationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Navigation("ReorderSettings");
b.Navigation("UomConversions");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b =>
{
b.Navigation("Lines");
b.Navigation("Quotations");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.Navigation("Bins");
});
#pragma warning restore 612, 618
}
}
}
+19 -1
View File
@@ -1,8 +1,11 @@
using System.Text.Json.Serialization;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi;
@@ -15,7 +18,9 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
builder.Services.AddControllers();
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// EF Core + PostgreSQL
builder.Services.AddDbContext<ErpDbContext>(o =>
@@ -36,6 +41,19 @@ builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 §2)
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
// Cross-cutting + procurement services (docs/11 §3)
builder.Services.AddScoped<INumberSequenceService, NumberSequenceService>();
builder.Services.AddScoped<IRequisitionService, RequisitionService>();
builder.Services.AddScoped<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
@@ -0,0 +1,70 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class CategoryService : ICategoryService
{
private readonly IRepository<Category> _categories;
private readonly IUnitOfWork _uow;
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
{
_categories = categories;
_uow = uow;
}
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _categories.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(c => c.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.ToListAsync(ct);
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
{
var all = await _categories.Query().AsNoTracking()
.OrderBy(c => c.Name)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.ToListAsync(ct);
var byParent = all.ToLookup(c => c.ParentId);
List<CategoryTreeDto> Build(long? parentId) =>
byParent[parentId]
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
.ToList();
return Build(null);
}
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
{
if (request.ParentId is not null
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId };
await _categories.AddAsync(category, ct);
await _uow.SaveChangesAsync(ct);
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
}
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <summary>Category master business logic (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public interface ICategoryService
{
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default);
Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,28 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Item master business logic (docs/11-BACKEND-PHASE1.md §2.12.2). Returns DTOs;
/// entities never cross this boundary (00-CORE §4).
/// </summary>
public interface IItemService
{
Task<PagedResponse<ItemListItemDto>> ListAsync(
PageQuery query, EntityStatus? status, long? categoryId, TrackingMode? trackingMode, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(long itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> UpdateAsync(long itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(long itemId, EntityStatus status, CancellationToken ct = default);
Task<ItemReorderSettingsDto> UpdateReorderAsync(long itemId, UpdateReorderRequest request, CancellationToken ct = default);
Task<ItemUomConversionsDto> UpdateUomConversionsAsync(long itemId, UpdateUomConversionsRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Issues gap-controlled, per-year document numbers (FR-X-03). Call inside the
/// document's UoW transaction so the reserved number rolls back with the document
/// on failure.
/// </summary>
public interface INumberSequenceService
{
/// <summary>
/// Reserve and return the next number for <paramref name="docType"/> in the
/// current year, formatted e.g. <c>PO-2026-00042</c>.
/// </summary>
Task<string> NextAsync(string docType, CancellationToken ct = default);
}
@@ -0,0 +1,19 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-order business logic (docs/11 §3.3; FR-PROC-03..07).</summary>
public interface IPurchaseOrderService
{
Task<PagedResponse<PurchaseOrderSummaryDto>> ListAsync(
PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default);
Task<ETagged<PurchaseOrderDto>?> GetAsync(long poId, CancellationToken ct = default);
Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default);
Task<ETagged<PurchaseOrderDto>> UpdateAsync(long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseOrderDto> ApproveAsync(long poId, CancellationToken ct = default);
Task<PurchaseOrderDto> CancelAsync(long poId, string? reason, CancellationToken ct = default);
}
@@ -0,0 +1,13 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
public interface IRequisitionService
{
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<RequisitionDto?> GetAsync(long requisitionId, CancellationToken ct = default);
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
Task<RequisitionDto> SubmitAsync(long requisitionId, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>RFQ &amp; vendor-quotation business logic (docs/11 §3.2).</summary>
public interface IRfqService
{
Task<RfqDto?> GetAsync(long rfqId, CancellationToken ct = default);
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
Task<VendorQuotationDto> AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default);
Task<RfqComparisonDto> GetComparisonAsync(long rfqId, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService
{
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor master business logic (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public interface IVendorService
{
Task<PagedResponse<VendorDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<VendorDto>?> GetAsync(long vendorId, CancellationToken ct = default);
Task<ETagged<VendorDto>> CreateAsync(CreateVendorRequest request, CancellationToken ct = default);
Task<ETagged<VendorDto>> UpdateAsync(long vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(long vendorId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
namespace ERPCore.Services.Interfaces;
/// <summary>Warehouse &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public interface IWarehouseService
{
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<WarehouseDto?> GetAsync(long warehouseId, CancellationToken ct = default);
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
Task<IReadOnlyList<BinDto>> ListBinsAsync(long warehouseId, CancellationToken ct = default);
Task<BinDto> CreateBinAsync(long warehouseId, CreateBinRequest request, CancellationToken ct = default);
}
+282
View File
@@ -0,0 +1,282 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per
/// docs/11-BACKEND-PHASE1.md §2.12.2 and 02-SECURITY C.1.
/// </summary>
public sealed class ItemService : IItemService
{
private readonly IRepository<Item> _items;
private readonly IRepository<Category> _categories;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IUnitOfWork _uow;
public ItemService(
IRepository<Item> items,
IRepository<Category> categories,
IRepository<Uom> uoms,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IUnitOfWork uow)
{
_items = items;
_categories = categories;
_uoms = uoms;
_vendors = vendors;
_warehouses = warehouses;
_uow = uow;
}
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
PageQuery query, EntityStatus? status, long? categoryId, TrackingMode? trackingMode, CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(i => i.Sku)
.Skip(query.Skip).Take(query.PageSize)
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(long itemId, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
public async Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default)
{
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
var item = new Item
{
Sku = request.Sku.Trim(),
Name = request.Name.Trim(),
Description = request.Description,
CategoryId = request.CategoryId,
BaseUomId = request.BaseUomId,
DefaultVendorId = request.DefaultVendorId,
ItemType = request.ItemType,
TrackingMode = request.TrackingMode,
TaxClass = request.TaxClass,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _items.AddAsync(item, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
public async Task<ETagged<ItemDetailDto>> UpdateAsync(
long itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
if (item.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
if (!string.Equals(item.Sku, request.Sku, StringComparison.Ordinal)
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
item.Description = request.Description;
item.CategoryId = request.CategoryId;
item.BaseUomId = request.BaseUomId;
item.DefaultVendorId = request.DefaultVendorId;
item.ItemType = request.ItemType;
item.TrackingMode = request.TrackingMode;
item.TaxClass = request.TaxClass;
item.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync(ct);
return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
public async Task SetStatusAsync(long itemId, EntityStatus status, CancellationToken ct = default)
{
var item = await _items.GetByIdAsync(itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
item.Status = status;
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
public async Task<ItemReorderSettingsDto> UpdateReorderAsync(
long itemId, UpdateReorderRequest request, CancellationToken ct = default)
{
if (request.Settings.Select(s => s.WarehouseId).Distinct().Count() != request.Settings.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate warehouseId in reorder settings.", 400);
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
foreach (var warehouseId in request.Settings.Select(s => s.WarehouseId))
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
// Full-replacement upsert (avoids delete+insert clashes on the unique index).
foreach (var stale in item.ReorderSettings.Where(r => request.Settings.All(s => s.WarehouseId != r.WarehouseId)).ToList())
item.ReorderSettings.Remove(stale);
foreach (var input in request.Settings)
{
var existing = item.ReorderSettings.FirstOrDefault(r => r.WarehouseId == input.WarehouseId);
if (existing is null)
{
item.ReorderSettings.Add(new ItemReorder
{
ItemId = itemId,
WarehouseId = input.WarehouseId,
ReorderPoint = input.ReorderPoint,
ReorderQty = input.ReorderQty
});
}
else
{
existing.ReorderPoint = input.ReorderPoint;
existing.ReorderQty = input.ReorderQty;
}
}
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var settings = item.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList();
return new ItemReorderSettingsDto(settings);
}
public async Task<ItemUomConversionsDto> UpdateUomConversionsAsync(
long itemId, UpdateUomConversionsRequest request, CancellationToken ct = default)
{
var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList();
if (pairs.Distinct().Count() != pairs.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate (fromUom, toUom) in conversions.", 400);
var item = await _items.Query()
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
foreach (var uomId in request.Conversions.SelectMany(c => new[] { c.FromUom, c.ToUom }).Distinct())
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList())
item.UomConversions.Remove(stale);
foreach (var input in request.Conversions)
{
var existing = item.UomConversions.FirstOrDefault(c => c.FromUomId == input.FromUom && c.ToUomId == input.ToUom);
if (existing is null)
{
item.UomConversions.Add(new UomConversion
{
ItemId = itemId,
FromUomId = input.FromUom,
ToUomId = input.ToUom,
Factor = input.Factor
});
}
else
{
existing.Factor = input.Factor;
}
}
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var conversions = item.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList();
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
}
private async Task ValidateReferencesAsync(long categoryId, long baseUomId, long? defaultVendorId, CancellationToken ct)
{
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
if (defaultVendorId is not null)
{
var vendor = await _vendors.Query().AsNoTracking()
.FirstOrDefaultAsync(v => v.VendorId == defaultVendorId, ct);
if (vendor is null)
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422);
if (vendor.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} is inactive.", 422);
}
}
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
}
}
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
@@ -0,0 +1,58 @@
using System.Data;
using ERPCore.Infra.Persistence;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Services;
/// <summary>
/// Atomic document-number issuer. Uses a single <c>INSERT … ON CONFLICT … DO UPDATE
/// … RETURNING</c> so concurrent issues for the same (docType, year) cannot get the
/// same number (the row is locked for the duration of the upsert). Runs as a direct
/// ADO.NET command enlisted in the DbContext's current transaction so it commits or
/// rolls back with the document (FR-X-03). EF's <c>SqlQuery</c> is avoided here
/// because it wraps the statement in a subquery, which PostgreSQL disallows for a
/// data-modifying statement.
/// </summary>
public sealed class NumberSequenceService : INumberSequenceService
{
private readonly ErpDbContext _db;
public NumberSequenceService(ErpDbContext db) => _db = db;
public async Task<string> NextAsync(string docType, CancellationToken ct = default)
{
var year = DateTime.UtcNow.Year;
var conn = _db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction();
cmd.CommandText = """
INSERT INTO number_sequences (doc_type, year, last_number)
VALUES (@docType, @year, 1)
ON CONFLICT (doc_type, year)
DO UPDATE SET last_number = number_sequences.last_number + 1
RETURNING last_number;
""";
AddParam(cmd, "docType", docType);
AddParam(cmd, "year", year);
var result = await cmd.ExecuteScalarAsync(ct)
?? throw new InvalidOperationException($"Failed to issue a document number for '{docType}'.");
var next = Convert.ToInt64(result);
return $"{docType}-{year}-{next:D5}";
}
private static void AddParam(IDbCommand cmd, string name, object value)
{
var p = cmd.CreateParameter();
p.ParameterName = name;
p.Value = value;
cmd.Parameters.Add(p);
}
}
@@ -0,0 +1,246 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Purchase-order service. Phase 1 auto-approves on creation (FR-PROC-04),
/// PO is freely editable while open (FR-PROC-05), totals are computed server-side
/// (02-SECURITY C.2), and cancel is blocked once any receipt exists.
/// </summary>
public sealed class PurchaseOrderService : IPurchaseOrderService
{
private const string BaseCurrency = "LKR";
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Requisition> _requisitions;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public PurchaseOrderService(
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_pos = pos;
_vendors = vendors;
_requisitions = requisitions;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<PurchaseOrderSummaryDto>> ListAsync(
PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default)
{
var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(p => EF.Functions.ILike(p.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(p => p.Status == status);
if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(p => p.PoId)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
var summaries = rows.Select(p => new PurchaseOrderSummaryDto(
p.PoId, p.DocNo, p.VendorId, p.Status, p.ApprovalRequired, p.CreatedAt, ComputeTotals(p.Lines))).ToList();
return PagedResponse<PurchaseOrderSummaryDto>.Create(summaries, query.Page, query.PageSize, total);
}
public async Task<ETagged<PurchaseOrderDto>?> GetAsync(long poId, CancellationToken ct = default)
{
var po = await _pos.Query().AsNoTracking()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct);
return po is null ? null : new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
}
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
var actor = _currentUser.AuditUserId;
var po = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseOrder, token);
var entity = new PurchaseOrder
{
DocNo = docNo,
VendorId = request.VendorId,
RequisitionId = request.RequisitionId,
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04).
ApprovalRequired = false,
Status = PurchaseOrderStatus.Approved,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(ToLine).ToList()
};
await _pos.AddAsync(entity, token);
return entity;
}, ct);
return new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
}
public async Task<ETagged<PurchaseOrderDto>> UpdateAsync(
long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412);
if (!IsEditable(po.Status))
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be edited.", 409);
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
po.VendorId = request.VendorId;
po.RequisitionId = request.RequisitionId;
po.UpdatedAt = DateTime.UtcNow;
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
po.Lines.Clear();
foreach (var input in request.Lines)
po.Lines.Add(ToLine(input));
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412);
}
return new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
}
public async Task<PurchaseOrderDto> ApproveAsync(long poId, CancellationToken ct = default)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
// Phase 1 no-op: POs are already Approved on creation. Kept for the future
// approval workflow (PendingApproval → Approved) — FR-PROC-04.
if (po.Status == PurchaseOrderStatus.PendingApproval)
{
po.Status = PurchaseOrderStatus.Approved;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
return Map(po);
}
public async Task<PurchaseOrderDto> CancelAsync(long poId, string? reason, CancellationToken ct = default)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Lines.Any(l => l.QtyReceived > 0))
throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it.");
if (po.Status != PurchaseOrderStatus.Cancelled)
{
po.Status = PurchaseOrderStatus.Cancelled;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
return Map(po);
}
private static bool IsEditable(PurchaseOrderStatus status) => status is not (
PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled);
private static PoLine ToLine(CreatePoLineInput l) => new()
{
ItemId = l.ItemId,
UomId = l.UomId,
WarehouseId = l.WarehouseId,
Qty = l.Qty,
UnitPrice = l.UnitPrice,
Tax = l.Tax,
QtyReceived = 0
};
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
{
decimal sub = 0, tax = 0;
foreach (var l in lines)
{
var net = l.Qty * l.UnitPrice;
sub += net;
tax += net * l.Tax;
}
sub = Math.Round(sub, 2, MidpointRounding.AwayFromZero);
tax = Math.Round(tax, 2, MidpointRounding.AwayFromZero);
return new PoTotalsDto(sub, tax, sub + tax, BaseCurrency);
}
private async Task ValidateReferencesAsync(
long vendorId, long? requisitionId, IReadOnlyCollection<CreatePoLineInput> lines, CancellationToken ct)
{
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
if (vendor is null)
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
if (vendor.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422);
if (requisitionId is not null
&& !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct))
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct);
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
}
private static async Task EnsureAllExistAsync(
IQueryable<long> keySource, IEnumerable<long> requested, string label, CancellationToken ct)
{
var ids = requested.Distinct().ToList();
var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct);
var missing = ids.Except(found).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422);
}
private static PurchaseOrderDto Map(PurchaseOrder p) => new(
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
}
@@ -0,0 +1,119 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class RequisitionService : IRequisitionService
{
private readonly IRepository<Requisition> _requisitions;
private readonly IRepository<Item> _items;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public RequisitionService(
IRepository<Requisition> requisitions, IRepository<Item> items,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_requisitions = requisitions;
_items = items;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _requisitions.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.RequisitionId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt))
.ToListAsync(ct);
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<RequisitionDto?> GetAsync(long requisitionId, CancellationToken ct = default)
{
var req = await _requisitions.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct);
return req is null ? null : Map(req);
}
public async Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default)
{
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
var actor = _currentUser.AuditUserId;
var req = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Requisition, token);
var entity = new Requisition
{
DocNo = docNo,
RequestedBy = actor,
Status = RequisitionStatus.Draft,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new RequisitionLine
{
ItemId = l.ItemId,
Qty = l.Qty,
RequiredBy = l.RequiredBy
}).ToList()
};
await _requisitions.AddAsync(entity, token);
return entity;
}, ct);
return Map(req);
}
public async Task<RequisitionDto> SubmitAsync(long requisitionId, CancellationToken ct = default)
{
var req = await _requisitions.Query()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct)
?? throw new NotFoundException($"Requisition {requisitionId} was not found.");
if (req.Status != RequisitionStatus.Submitted)
{
req.Status = RequisitionStatus.Submitted;
await _uow.SaveChangesAsync(ct);
}
return Map(req);
}
private async Task EnsureItemsExistAsync(IEnumerable<long> itemIds, CancellationToken ct)
{
var ids = itemIds.Distinct().ToList();
var found = await _items.Query().AsNoTracking()
.Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct);
var missing = ids.Except(found).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422);
}
private static RequisitionDto Map(Requisition r) => new(
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt,
r.Lines.OrderBy(l => l.ReqLineId)
.Select(l => new RequisitionLineDto(l.ReqLineId, l.ItemId, l.Qty, l.RequiredBy))
.ToList());
}
+157
View File
@@ -0,0 +1,157 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class RfqService : IRfqService
{
private readonly IRepository<Rfq> _rfqs;
private readonly IRepository<Requisition> _requisitions;
private readonly IRepository<Item> _items;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<VendorQuotation> _quotations;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
public RfqService(
IRepository<Rfq> rfqs, IRepository<Requisition> requisitions, IRepository<Item> items,
IRepository<Vendor> vendors, IRepository<VendorQuotation> quotations,
INumberSequenceService numbers, IUnitOfWork uow)
{
_rfqs = rfqs;
_requisitions = requisitions;
_items = items;
_vendors = vendors;
_quotations = quotations;
_numbers = numbers;
_uow = uow;
}
public async Task<RfqDto?> GetAsync(long rfqId, CancellationToken ct = default)
{
var rfq = await _rfqs.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.RfqId == rfqId, ct);
return rfq is null ? null : MapRfq(rfq);
}
public async Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default)
{
if (!await _requisitions.Query().AnyAsync(r => r.RequisitionId == request.RequisitionId, ct))
throw new DomainException(ErrorCodes.Validation, $"Requisition {request.RequisitionId} does not exist.", 422);
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
await EnsureVendorsExistAsync(request.VendorIds, ct);
var rfq = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Rfq, token);
var entity = new Rfq
{
DocNo = docNo,
RequisitionId = request.RequisitionId,
Status = RfqStatus.Open,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new RfqLine { ItemId = l.ItemId, Qty = l.Qty }).ToList()
};
await _rfqs.AddAsync(entity, token);
return entity;
}, ct);
return MapRfq(rfq);
}
public async Task<VendorQuotationDto> AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default)
{
var rfq = await _rfqs.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.RfqId == rfqId, ct)
?? throw new NotFoundException($"RFQ {rfqId} was not found.");
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var rfqItemIds = rfq.Lines.Select(l => l.ItemId).ToHashSet();
var offLine = request.Lines.Select(l => l.ItemId).FirstOrDefault(id => !rfqItemIds.Contains(id));
if (offLine != 0)
throw new DomainException(ErrorCodes.Validation, $"Item {offLine} is not part of RFQ {rfqId}.", 422);
if (await _quotations.Query().AnyAsync(q => q.RfqId == rfqId && q.VendorId == request.VendorId, ct))
throw new ConflictException($"Vendor {request.VendorId} has already quoted RFQ {rfqId}.");
var quotation = new VendorQuotation
{
RfqId = rfqId,
VendorId = request.VendorId,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new VendorQuotationLine
{
ItemId = l.ItemId,
UnitPrice = l.UnitPrice,
LeadDays = l.LeadDays
}).ToList()
};
await _quotations.AddAsync(quotation, ct);
await _uow.SaveChangesAsync(ct);
return new VendorQuotationDto(
quotation.QuotationId, quotation.RfqId, quotation.VendorId,
quotation.Lines.Select(l => new QuotationLineDto(l.ItemId, l.UnitPrice, l.LeadDays)).ToList());
}
public async Task<RfqComparisonDto> GetComparisonAsync(long rfqId, CancellationToken ct = default)
{
var rfq = await _rfqs.Query().AsNoTracking()
.Include(r => r.Lines)
.Include(r => r.Quotations).ThenInclude(q => q.Lines)
.FirstOrDefaultAsync(r => r.RfqId == rfqId, ct)
?? throw new NotFoundException($"RFQ {rfqId} was not found.");
var vendorIds = rfq.Quotations.Select(q => q.VendorId).Distinct().OrderBy(v => v).ToList();
var rows = rfq.Lines.OrderBy(l => l.RfqLineId).Select(line =>
{
var cells = rfq.Quotations
.Select(q => new { q.VendorId, q.QuotationId, Line = q.Lines.FirstOrDefault(ql => ql.ItemId == line.ItemId) })
.Where(x => x.Line is not null)
.OrderBy(x => x.VendorId)
.Select(x => new RfqComparisonCellDto(x.VendorId, x.QuotationId, x.Line!.UnitPrice, x.Line!.LeadDays))
.ToList();
return new RfqComparisonRowDto(line.ItemId, line.Qty, cells);
}).ToList();
return new RfqComparisonDto(rfqId, vendorIds, rows);
}
private async Task EnsureItemsExistAsync(IEnumerable<long> itemIds, CancellationToken ct)
{
var ids = itemIds.Distinct().ToList();
var found = await _items.Query().AsNoTracking()
.Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct);
var missing = ids.Except(found).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422);
}
private async Task EnsureVendorsExistAsync(IEnumerable<long> vendorIds, CancellationToken ct)
{
var ids = vendorIds.Distinct().ToList();
if (ids.Count == 0) return;
var found = await _vendors.Query().AsNoTracking()
.Where(v => ids.Contains(v.VendorId)).Select(v => v.VendorId).ToListAsync(ct);
var missing = ids.Except(found).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Vendor(s) not found: {string.Join(", ", missing)}.", 422);
}
private static RfqDto MapRfq(Rfq r) => new(
r.RfqId, r.DocNo, r.RequisitionId, r.Status,
r.Lines.OrderBy(l => l.RfqLineId).Select(l => new RfqLineDto(l.RfqLineId, l.ItemId, l.Qty)).ToList());
}
+53
View File
@@ -0,0 +1,53 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class UomService : IUomService
{
private readonly IRepository<Uom> _uoms;
private readonly IUnitOfWork _uow;
public UomService(IRepository<Uom> uoms, IUnitOfWork uow)
{
_uoms = uoms;
_uow = uow;
}
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name))
.ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default)
{
var name = request.Name.Trim();
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name };
await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name);
}
}
+118
View File
@@ -0,0 +1,118 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class VendorService : IVendorService
{
private readonly IRepository<Vendor> _vendors;
private readonly IUnitOfWork _uow;
public VendorService(IRepository<Vendor> vendors, IUnitOfWork uow)
{
_vendors = vendors;
_uow = uow;
}
public async Task<PagedResponse<VendorDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _vendors.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(v => EF.Functions.ILike(v.Code, $"%{term}%") || EF.Functions.ILike(v.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(v => v.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(v => v.Code)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
return PagedResponse<VendorDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<VendorDto>?> GetAsync(long vendorId, CancellationToken ct = default)
{
var vendor = await _vendors.Query().AsNoTracking()
.FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
return vendor is null ? null : new ETagged<VendorDto>(Map(vendor), vendor.RowVersion);
}
public async Task<ETagged<VendorDto>> CreateAsync(CreateVendorRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _vendors.Query().AnyAsync(v => v.Code == code, ct))
throw new ConflictException($"A vendor with code '{code}' already exists.");
var vendor = new Vendor
{
Code = code,
Name = request.Name.Trim(),
Terms = request.Terms,
TaxReg = request.TaxReg,
Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _vendors.AddAsync(vendor, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<VendorDto>(Map(vendor), vendor.RowVersion);
}
public async Task<ETagged<VendorDto>> UpdateAsync(
long vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var vendor = await _vendors.GetByIdAsync(vendorId, ct)
?? throw new NotFoundException($"Vendor {vendorId} was not found.");
if (vendor.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The vendor was modified by another request.", 412);
var code = request.Code.Trim();
if (!string.Equals(vendor.Code, code, StringComparison.Ordinal)
&& await _vendors.Query().AnyAsync(v => v.Code == code && v.VendorId != vendorId, ct))
throw new ConflictException($"A vendor with code '{code}' already exists.");
vendor.Code = code;
vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg;
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The vendor was modified by another request.", 412);
}
return new ETagged<VendorDto>(Map(vendor), vendor.RowVersion);
}
public async Task SetStatusAsync(long vendorId, EntityStatus status, CancellationToken ct = default)
{
var vendor = await _vendors.GetByIdAsync(vendorId, ct)
?? throw new NotFoundException($"Vendor {vendorId} was not found.");
vendor.Status = status;
vendor.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.CreatedAt, v.UpdatedAt);
}
@@ -0,0 +1,94 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class WarehouseService : IWarehouseService
{
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IUnitOfWork _uow;
public WarehouseService(IRepository<Warehouse> warehouses, IRepository<Bin> bins, IUnitOfWork uow)
{
_warehouses = warehouses;
_bins = bins;
_uow = uow;
}
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _warehouses.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name))
.ToListAsync(ct);
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<WarehouseDto?> GetAsync(long warehouseId, CancellationToken ct = default)
{
var w = await _warehouses.Query().AsNoTracking()
.FirstOrDefaultAsync(x => x.WarehouseId == warehouseId, ct);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name);
}
public async Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists.");
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim() };
await _warehouses.AddAsync(warehouse, ct);
await _uow.SaveChangesAsync(ct);
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name);
}
public async Task<IReadOnlyList<BinDto>> ListBinsAsync(long warehouseId, CancellationToken ct = default)
{
await EnsureWarehouseExistsAsync(warehouseId, ct);
return await _bins.Query().AsNoTracking()
.Where(b => b.WarehouseId == warehouseId)
.OrderBy(b => b.Code)
.Select(b => new BinDto(b.BinId, b.WarehouseId, b.Code, b.BinType))
.ToListAsync(ct);
}
public async Task<BinDto> CreateBinAsync(long warehouseId, CreateBinRequest request, CancellationToken ct = default)
{
await EnsureWarehouseExistsAsync(warehouseId, ct);
var code = request.Code.Trim();
if (await _bins.Query().AnyAsync(b => b.WarehouseId == warehouseId && b.Code == code, ct))
throw new ConflictException($"Bin '{code}' already exists in warehouse {warehouseId}.");
var bin = new Bin { WarehouseId = warehouseId, Code = code, BinType = request.BinType };
await _bins.AddAsync(bin, ct);
await _uow.SaveChangesAsync(ct);
return new BinDto(bin.BinId, bin.WarehouseId, bin.Code, bin.BinType);
}
private async Task EnsureWarehouseExistsAsync(long warehouseId, CancellationToken ct)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
}
}
+14 -1
View File
@@ -3,12 +3,25 @@ namespace ERPCore.System.Errors;
/// <summary>
/// Stable domain error codes carried in the <c>code</c> extension of RFC 7807
/// ProblemDetails responses. The authoritative catalog lives in
/// docs/11-BACKEND-PHASE1.md; add codes here as endpoints are implemented so
/// docs/11-BACKEND-PHASE1.md §7; add codes here as endpoints are implemented so
/// the two stay in sync.
/// </summary>
public static class ErrorCodes
{
// Generic (framework-level) helpers used by the exception types below.
public const string Validation = "validation_error";
public const string NotFound = "not_found";
public const string Conflict = "conflict";
// Catalog (docs/11 §7) — exact strings surfaced to clients.
public const string SkuDuplicate = "SKU_DUPLICATE";
public const string MasterInUse = "MASTER_IN_USE";
public const string PoNotEditable = "PO_NOT_EDITABLE";
public const string OverReceiptTolerance = "OVER_RECEIPT_TOLERANCE";
public const string StockNegativeBlocked = "STOCK_NEGATIVE_BLOCKED";
public const string ExpiredBatchBlocked = "EXPIRED_BATCH_BLOCKED";
public const string OnHoldNotIssuable = "ONHOLD_NOT_ISSUABLE";
public const string ReasonCodeRequired = "REASON_CODE_REQUIRED";
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=postgres"
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
},
"Jwt": {
"SigningKey": "dev-only-signing-key-please-change-me-0123456789"
+48 -21
View File
@@ -5,29 +5,34 @@ Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md`
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
## 0. Bootstrap
- [ ] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
- [ ] Folder structure per 00-CORE §5.3
- [ ] `ErpDbContext` + Npgsql wired; `InitialCreate` migration applied
- [ ] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs`
- [ ] `IUnitOfWork` + `UnitOfWork` (transaction boundary)
- [ ] Generic repository base + interfaces
- [ ] `ICurrentUser` (audit stamp from token `sub`)
- [ ] ProblemDetails middleware + domain exception → `code` mapping (System/Errors)
- [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
- [x] Folder structure per 00-CORE §5.3
- [x] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created and applied** (2026-07-10, 8 master-data tables). `/health``Healthy`.
- [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` (JWT bearer *validated*; endpoints not yet `[Authorize]`-gated — see §6 auth note)
- [x] `IUnitOfWork` + `UnitOfWork` (transaction boundary)
- [x] Generic repository base + interfaces
- [x] `ICurrentUser` (audit stamp from token `sub`)
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)
## 1. Master Data
- [ ] Item: entity + config + enums (ItemType, TrackingMode)
- [ ] Item: repository + service + controller (CRUD, DTOs, ETag)
- [ ] UOM + UOM conversions
- [ ] Category (hierarchy, `?tree=true`)
- [ ] Vendor
- [ ] Warehouse + Bin
- [ ] Item reorder settings (`PUT /items/{id}/reorder`)
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. Still `[~]` (not `[x]`) for **one** reason: the **security gate** (00-CORE §8) — the foundational auth control (02-SECURITY B.1) and the audit trail (B.3, the AR-01 compensating control) land in §6. Flip to `[x]` once §6 auth+audit are wired.
- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
- [~] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
- [~] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
- [~] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation)
- [~] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
- [~] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
- [~] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
## 2. Procurement
- [ ] Requisition (+ lines) + submit
- [ ] RFQ + quotations + comparison
- [ ] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open, approve (no-op), cancel
- [ ] Purchase Return (outbound movement, reason code)
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
- [~] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
- [~] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
- [~] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
- [ ] Purchase Return (outbound movement, reason code) — **deferred**: needs GRN lines + stock ledger/FIFO (§3/§4). Build with those.
> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly.
## 3. Goods Receipt
- [ ] GRN create (against PO / direct), over-receipt tolerance
@@ -47,9 +52,10 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [ ] Reorder alerts (query) + suggest requisition
## 6. Cross-cutting
> **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so §1 endpoints are currently open. This is the AR-01/NFR-03 control surface — gate all v1 endpoints (fallback authorization policy) in the same change as `POST /auth/login`, then re-run the 02-SECURITY B.1 checklist and flip §1 items to `[x]`.
- [ ] Audit log on every mutation (who/when/old→new)
- [ ] Document numbering sequences (per type, per year)
- [ ] Auth: simple in-app login → JWT (`POST /auth/login`)
- [x] Document numbering sequences (per type, per year)`NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
- [~] Auth: simple in-app login → JWT (`POST /auth/login`) — foundation only: `User` table + seeded `system` user (id 1) exist and `ICurrentUser.AuditUserId` stamps docs; login endpoint + `[Authorize]` still pending.
- [ ] JournalEntryStub emitted per stock movement (data only)
- [ ] Negative-stock policy enforcement (default block)
- [ ] FEFO picking for perishables; block expired / on-hold issue
@@ -61,3 +67,24 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## Done
<!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-09 — Bootstrap verified + Master Data (§1) implemented
- Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, `Program.cs` wiring, UoW, generic repo, `ICurrentUser`, ProblemDetails handler). Added enum-as-string JSON (`JsonStringEnumConverter`) and registered the 5 master-data services.
- Domain: 3 enums (`ItemType`, `TrackingMode`, `EntityStatus`) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one `IEntityTypeConfiguration` each; FKs `Restrict` (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, `xmin` concurrency token on Item/Vendor.
- API: 5 controllers, lowercase routes matching `docs/11 §2` exactly (verified via generated `swagger.json`). ETag/If-Match (428 if missing, 412 on mismatch), narrow request DTOs (no over-posting), `PagedResponse<T>` list envelope (§1.4), `PageQuery` with pageSize clamp ≤200 (B.6).
- Migration `InitialCreate` generated (`xmin` correctly produces no DDL — uses the PG system column).
- **Verified:** `dotnet build` clean (0 warn/0 err); app boots (`Now listening… Application started`); `/api/meta` 200; `swagger.json` 200 with all 13 master-data paths; DI resolves controller→service→repo→DbContext (a DB-backed call reaches Npgsql, failing only on creds).
- **Blocked / follow-ups:** (1) auth enforcement + audit trail — §6 (the remaining security gate for `[x]`); (2) no `DELETE` master endpoints — `MASTER_IN_USE` code reserved until transaction tables exist (deactivate-only per FR-MD-08); (3) minor: bad-enum bind error leaks the CLR type name in `detail` (02-SECURITY B.5) — fine in Dev, tidy before prod.
### 2026-07-10 — Migration applied + live smoke test PASSED
- `dotnet ef database update` applied `InitialCreate` to local Postgres; `/health``Healthy`.
- End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child + `?tree=true` nesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filter `q` + `pageSize=9999`→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400 `SKU_DUPLICATE`; bad reference→422; missing required→400 ValidationProblemDetails; bad enum→400. Concurrency token (`xmin`) confirmed incrementing per mutation.
- Note: local dev DB now holds smoke-test rows (warehouse/bin/uom×2/category×2/vendor/item, item left Inactive). Reset any time with `dotnet ef database drop -f && dotnet ef database update`.
### 2026-07-10 — Procurement (§2, minus returns) + cross-cutting foundations
- Cross-cutting: `User` entity (+ seeded `system` user via `HasData`), `ICurrentUser.AuditUserId` (numeric actor, system fallback), `NumberSequence` + `NumberSequenceService` (atomic per-type/per-year doc numbers issued inside the UoW txn).
- Procurement: enums (RequisitionStatus, RfqStatus, PurchaseOrderStatus); 8 entities (Requisition/Line, Rfq/Line, VendorQuotation/Line, PurchaseOrder/Line) + configs; DTOs; 3 services; 3 controllers (`/requisitions`, `/rfqs`, `/purchase-orders`). PO carries the `xmin` ETag token; totals computed server-side; create/edit wrapped in `IUnitOfWork.ExecuteInTransactionAsync` so the reserved doc number rolls back with the doc.
- Migration `AddProcurement` generated + applied (10 tables incl. users/number_sequences; system-user seed; PO `xmin` emits no DDL).
- **Verified:** build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422.
- **Deferred/next:** Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to `[x]`.
- Housekeeping: an empty user-created migration `20260709124415_initial` sits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning).
+3 -20
View File
@@ -6,11 +6,8 @@ import Link from "next/link"
import { useRouter } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { AlertCircle, Eye, EyeOff } from "lucide-react"
import { Eye, EyeOff } from "lucide-react"
import { loginSchema, LoginValues } from "@/lib/validations"
import { loginUser } from "@/lib/api/auth"
import { AuthApiError } from "@/lib/api/http"
import { persistSession } from "@/lib/auth/session"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -44,7 +41,6 @@ export default function LoginPage() {
const router = useRouter()
const [showPassword, setShowPassword] = useState(false)
const [remember, setRemember] = useState(false)
const [authError, setAuthError] = useState<string | null>(null)
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
@@ -52,14 +48,8 @@ export default function LoginPage() {
})
const onSubmit = form.handleSubmit(async (values) => {
setAuthError(null)
try {
const result = await loginUser({ identifier: values.email, password: values.password })
persistSession(result, remember)
router.push("/dashboard")
} catch (err) {
setAuthError(err instanceof AuthApiError ? err.message : "Something went wrong. Please try again.")
}
console.log(values)
router.push("/dashboard")
})
return (
@@ -89,13 +79,6 @@ export default function LoginPage() {
</div>
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
{authError && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{authError}</span>
</div>
)}
<FieldGroup className="space-y-5">
<Field data-invalid={!!form.formState.errors.email}>
<FieldLabel htmlFor="email" className="text-sm font-semibold text-foreground">
-30
View File
@@ -1,30 +0,0 @@
import { callAuthFunction } from "@/lib/api/http"
export interface AuthUser {
id: string
fullName: string | null
userName: string | null
email: string | null
mobileNumber: string | null
emailVerified: boolean
mobileNumberVerified: boolean
isMfaEnabled: boolean
roleId: string | null
userTypeId: string | null
}
export interface LoginResult {
accessToken: string
refreshToken: string
expiresIn: number
user: AuthUser
}
/** POST /api/loginUser (forced functionName "loginUser" server-side). */
export function loginUser(params: { identifier: string; password: string; deviceName?: string }) {
return callAuthFunction<LoginResult>("/api/loginUser", "loginUser", {
identifier: params.identifier,
password: params.password,
deviceName: params.deviceName ?? (typeof navigator !== "undefined" ? navigator.userAgent : "Unknown Device"),
})
}
-67
View File
@@ -1,67 +0,0 @@
// Thin client for the AuthHex service's function-dispatch envelope
// (POST { functionName, payload, reference } -> { statusCode, success, message, data }).
// See D:\HexDive\ERP_Auth_Service\API_DOCUMENTATION.md for the full contract.
const AUTH_API_BASE_URL =
process.env.NEXT_PUBLIC_AUTH_API_BASE_URL?.replace(/\/$/, "") ?? "https://localhost:7111"
interface ApiEnvelope<T> {
statusCode: number
success: boolean
message: string | null
data: T | null
}
export class AuthApiError extends Error {
statusCode: number
constructor(message: string, statusCode: number) {
super(message)
this.name = "AuthApiError"
this.statusCode = statusCode
}
}
function makeReference() {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `req-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
/**
* Calls one function on an AuthHex route (e.g. POST /api/loginUser).
* The service's own `success` flag is not reliable on error paths (it defaults
* to true even when statusCode is 4xx/5xx), so failure is judged from
* `!response.ok || body.statusCode !== 200`, not from `body.success`.
*/
export async function callAuthFunction<T>(
route: string,
functionName: string,
payload: Record<string, unknown> = {}
): Promise<T> {
let response: Response
try {
response = await fetch(`${AUTH_API_BASE_URL}${route}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ functionName, payload, reference: makeReference() }),
})
} catch {
throw new AuthApiError(
"Could not reach the authentication service. Check your connection and try again.",
0
)
}
let body: ApiEnvelope<T> | null = null
try {
body = (await response.json()) as ApiEnvelope<T>
} catch {
// non-JSON body — body stays null, handled below
}
if (!response.ok || !body || body.statusCode !== 200) {
throw new AuthApiError(body?.message || `Request failed (${response.status})`, body?.statusCode ?? response.status)
}
return body.data as T
}
-39
View File
@@ -1,39 +0,0 @@
import type { AuthUser, LoginResult } from "@/lib/api/auth"
const ACCESS_TOKEN_KEY = "hexa_erp_access_token"
const REFRESH_TOKEN_KEY = "hexa_erp_refresh_token"
const USER_KEY = "hexa_erp_user"
/** remember=true persists across browser restarts (localStorage); otherwise session-only. */
export function persistSession(result: LoginResult, remember: boolean) {
const store = remember ? window.localStorage : window.sessionStorage
const other = remember ? window.sessionStorage : window.localStorage
for (const key of [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY]) other.removeItem(key)
store.setItem(ACCESS_TOKEN_KEY, result.accessToken)
store.setItem(REFRESH_TOKEN_KEY, result.refreshToken)
store.setItem(USER_KEY, JSON.stringify(result.user))
}
export function getAccessToken(): string | null {
if (typeof window === "undefined") return null
return window.localStorage.getItem(ACCESS_TOKEN_KEY) ?? window.sessionStorage.getItem(ACCESS_TOKEN_KEY)
}
export function getStoredUser(): AuthUser | null {
if (typeof window === "undefined") return null
const raw = window.localStorage.getItem(USER_KEY) ?? window.sessionStorage.getItem(USER_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as AuthUser
} catch {
return null
}
}
export function clearSession() {
for (const key of [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY]) {
window.localStorage.removeItem(key)
window.sessionStorage.removeItem(key)
}
}
+13 -6
View File
@@ -15,14 +15,21 @@ export const emailSchema = z.preprocess(
z.string().min(1, "Email is required").email("Enter a valid email")
)
// Login schema (email + password) for reuse across the app.
// Password strength is intentionally NOT re-validated here: an existing
// account's password may predate current complexity rules, and only the
// server can judge whether credentials are actually correct. Complexity
// rules belong on registration/reset-password, not on login.
// Login schema (email + password) for reuse across the app
export const loginSchema = z.object({
email: emailSchema,
password: requiredString("Password is required"),
password: requiredString("Password is required")
.refine((val) => val.length >= 8, { message: "Password must be at least 8 characters" })
.refine((val) => /[A-Z]/.test(val), {
message: "Password must contain at least one uppercase letter",
})
.refine((val) => /[a-z]/.test(val), {
message: "Password must contain at least one lowercase letter",
})
.refine((val) => /[0-9]/.test(val), { message: "Password must contain at least one number" })
.refine((val) => /[!@#$%^&*(),.?":{}|<>\[\]\\/`~;'+=-]/.test(val), {
message: "Password must contain at least one special character",
}),
})
export type LoginValues = z.infer<typeof loginSchema>