diff --git a/Backend/ERPCore/Common/Http/ETag.cs b/Backend/ERPCore/Common/Http/ETag.cs new file mode 100644 index 0000000..711d165 --- /dev/null +++ b/Backend/ERPCore/Common/Http/ETag.cs @@ -0,0 +1,37 @@ +namespace ERPCore.Common.Http; + +/// +/// Encodes the PostgreSQL xmin concurrency token (a ) as an +/// opaque, quoted HTTP ETag and parses If-Match values back. Round-trips +/// via base64 so the value is stable and content-type agnostic +/// (docs/11-BACKEND-PHASE1.md §1.6). +/// +public static class ETag +{ + /// Quoted ETag string for a row-version token, e.g. "0RsAAA==". + public static string From(uint rowVersion) + => "\"" + Convert.ToBase64String(BitConverter.GetBytes(rowVersion)) + "\""; + + /// Parse an If-Match header value (quoted, optionally weak) to a token. + 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; + } + } +} diff --git a/Backend/ERPCore/Common/Http/ETagged.cs b/Backend/ERPCore/Common/Http/ETagged.cs new file mode 100644 index 0000000..527cf79 --- /dev/null +++ b/Backend/ERPCore/Common/Http/ETagged.cs @@ -0,0 +1,7 @@ +namespace ERPCore.Common.Http; + +/// +/// Pairs a response DTO with the aggregate's current row-version so the controller +/// can emit an ETag header without the token leaking into the JSON body. +/// +public sealed record ETagged(T Value, uint RowVersion); diff --git a/Backend/ERPCore/Controllers/ApiControllerBase.cs b/Backend/ERPCore/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000..ad4d1e3 --- /dev/null +++ b/Backend/ERPCore/Controllers/ApiControllerBase.cs @@ -0,0 +1,28 @@ +using ERPCore.Common.Http; +using ERPCore.System.Errors; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// 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 [Route] to match +/// the API contract paths (docs/11 §1.1). +/// +[ApiController] +[Produces("application/json")] +public abstract class ApiControllerBase : ControllerBase +{ + /// Parse a mandatory If-Match header, or 428 if absent/malformed. + 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; + } + + /// Emit the strong ETag response header for a row-version token. + protected void SetETag(uint rowVersion) => Response.Headers.ETag = ETag.From(rowVersion); +} diff --git a/Backend/ERPCore/Controllers/CategoriesController.cs b/Backend/ERPCore/Controllers/CategoriesController.cs new file mode 100644 index 0000000..dab7c60 --- /dev/null +++ b/Backend/ERPCore/Controllers/CategoriesController.cs @@ -0,0 +1,31 @@ +using ERPCore.Dtos.Categories; +using ERPCore.Dtos.Common; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3). +[Route("api/v1/categories")] +public sealed class CategoriesController : ApiControllerBase +{ + private readonly ICategoryService _categories; + + public CategoriesController(ICategoryService categories) => _categories = categories; + + /// Flat paged list, or a nested tree when tree=true. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task 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> Create([FromBody] CreateCategoryRequest request, CancellationToken ct) + { + var dto = await _categories.CreateAsync(request, ct); + return Created($"/api/v1/categories/{dto.CategoryId}", dto); + } +} diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs new file mode 100644 index 0000000..eab6c79 --- /dev/null +++ b/Backend/ERPCore/Controllers/ItemsController.cs @@ -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; + +/// Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2). +[Route("api/v1/items")] +public sealed class ItemsController : ApiControllerBase +{ + private readonly IItemService _items; + + public ItemsController(IItemService items) => _items = items; + + /// List items with optional filters and paging. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] EntityStatus? status, + [FromQuery] long? categoryId, + [FromQuery] TrackingMode? trackingMode, + CancellationToken ct) + => Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct)); + + /// Get a single item; returns an ETag for optimistic concurrency. + [HttpGet("{itemId:long}")] + [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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); + } + + /// Create an item (SKU unique). Server sets status and timestamps. + [HttpPost] + [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> 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); + } + + /// Full update; requires If-Match (412 on stale ETag). + [HttpPut("{itemId:long}")] + [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> 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); + } + + /// Activate / deactivate the item (FR-MD-08 — deactivate, not delete). + [HttpPatch("{itemId:long}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(long itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct) + { + await _items.SetStatusAsync(itemId, request.Status, ct); + return NoContent(); + } + + /// Replace the item's per-warehouse reorder settings (FR-MD-05). + [HttpPut("{itemId:long}/reorder")] + [ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateReorder(long itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct) + => Ok(await _items.UpdateReorderAsync(itemId, request, ct)); + + /// Replace the item's UOM conversions (FR-MD-02). + [HttpPut("{itemId:long}/uom-conversions")] + [ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateUomConversions(long itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct) + => Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct)); +} diff --git a/Backend/ERPCore/Controllers/UomsController.cs b/Backend/ERPCore/Controllers/UomsController.cs new file mode 100644 index 0000000..55219dc --- /dev/null +++ b/Backend/ERPCore/Controllers/UomsController.cs @@ -0,0 +1,29 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Uoms; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2). +[Route("api/v1/uoms")] +public sealed class UomsController : ApiControllerBase +{ + private readonly IUomService _uoms; + + public UomsController(IUomService uoms) => _uoms = uoms; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _uoms.ListAsync(query, ct)); + + [HttpPost] + [ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateUomRequest request, CancellationToken ct) + { + var dto = await _uoms.CreateAsync(request, ct); + return Created($"/api/v1/uoms/{dto.UomId}", dto); + } +} diff --git a/Backend/ERPCore/Controllers/VendorsController.cs b/Backend/ERPCore/Controllers/VendorsController.cs new file mode 100644 index 0000000..40b17b3 --- /dev/null +++ b/Backend/ERPCore/Controllers/VendorsController.cs @@ -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; + +/// Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4). +[Route("api/v1/vendors")] +public sealed class VendorsController : ApiControllerBase +{ + private readonly IVendorService _vendors; + + public VendorsController(IVendorService vendors) => _vendors = vendors; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> 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> 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 SetStatus(long vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct) + { + await _vendors.SetStatusAsync(vendorId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/WarehousesController.cs b/Backend/ERPCore/Controllers/WarehousesController.cs new file mode 100644 index 0000000..4c2eb5c --- /dev/null +++ b/Backend/ERPCore/Controllers/WarehousesController.cs @@ -0,0 +1,54 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Warehouses; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5). +[Route("api/v1/warehouses")] +public sealed class WarehousesController : ApiControllerBase +{ + private readonly IWarehouseService _warehouses; + + public WarehousesController(IWarehouseService warehouses) => _warehouses = warehouses; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> 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), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> 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> 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); + } +} diff --git a/Backend/ERPCore/Domain/Entities/Bin.cs b/Backend/ERPCore/Domain/Entities/Bin.cs new file mode 100644 index 0000000..331fce1 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Bin.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// +/// 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. +/// +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; } +} diff --git a/Backend/ERPCore/Domain/Entities/Category.cs b/Backend/ERPCore/Domain/Entities/Category.cs new file mode 100644 index 0000000..5285373 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Category.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Hierarchical item category (FR-MD-04). A null denotes a +/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +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 Children { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs new file mode 100644 index 0000000..4083f57 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -0,0 +1,39 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Item master (FR-MD-01). Mutable aggregate: carries a +/// concurrency token surfaced as an ETag (docs/10 Part C.10). SKU is unique. +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } + + public ICollection ReorderSettings { get; set; } = new List(); + public ICollection UomConversions { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/ItemReorder.cs b/Backend/ERPCore/Domain/Entities/ItemReorder.cs new file mode 100644 index 0000000..8a3ecfe --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ItemReorder.cs @@ -0,0 +1,20 @@ +namespace ERPCore.Domain.Entities; + +/// +/// 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. +/// +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; } +} diff --git a/Backend/ERPCore/Domain/Entities/Uom.cs b/Backend/ERPCore/Domain/Entities/Uom.cs new file mode 100644 index 0000000..2200e55 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Uom.cs @@ -0,0 +1,11 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the +/// endpoints of a . Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class Uom +{ + public long UomId { get; set; } + public string Name { get; set; } = string.Empty; +} diff --git a/Backend/ERPCore/Domain/Entities/UomConversion.cs b/Backend/ERPCore/Domain/Entities/UomConversion.cs new file mode 100644 index 0000000..fa76ef4 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/UomConversion.cs @@ -0,0 +1,22 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in +/// × = quantity in . +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +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; } +} diff --git a/Backend/ERPCore/Domain/Entities/Vendor.cs b/Backend/ERPCore/Domain/Entities/Vendor.cs new file mode 100644 index 0000000..22d1e09 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Vendor.cs @@ -0,0 +1,25 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Vendor master (FR-MD-06). Mutable aggregate with a +/// ETag token. Deactivated, not deleted, when referenced (FR-MD-08). +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Warehouse.cs b/Backend/ERPCore/Domain/Entities/Warehouse.cs new file mode 100644 index 0000000..fe7a402 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Warehouse.cs @@ -0,0 +1,14 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Warehouse master (FR-MD-07, FR-WH-01). Owns a bin/location hierarchy. +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class Warehouse +{ + public long WarehouseId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + + public ICollection Bins { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Enums/EntityStatus.cs b/Backend/ERPCore/Domain/Enums/EntityStatus.cs new file mode 100644 index 0000000..1a6c807 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/EntityStatus.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Lifecycle status for deactivatable master data (Item, Vendor). Masters are +/// never hard-deleted while referenced — they are set +/// instead (FR-MD-08). Stored as a string. +/// +public enum EntityStatus +{ + Active, + Inactive +} diff --git a/Backend/ERPCore/Domain/Enums/ItemType.cs b/Backend/ERPCore/Domain/Enums/ItemType.cs new file mode 100644 index 0000000..c81a1d6 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/ItemType.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Item classification (FR-MD-01). Values match the itemType enum in +/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database. +/// +public enum ItemType +{ + Stocked, + NonStocked, + Service +} diff --git a/Backend/ERPCore/Domain/Enums/TrackingMode.cs b/Backend/ERPCore/Domain/Enums/TrackingMode.cs new file mode 100644 index 0000000..23af2e4 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/TrackingMode.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// +/// How on-hand units of an item are individually tracked (FR-MD-01). Values match +/// the trackingMode enum in docs/11-BACKEND-PHASE1.md §8. Stored as a string. +/// +public enum TrackingMode +{ + None, + Batch, + Serial +} diff --git a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs new file mode 100644 index 0000000..856e23b --- /dev/null +++ b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Categories; + +/// Flat category resource (docs/11-BACKEND-PHASE1.md §2.3). +public sealed record CategoryDto(long CategoryId, string Name, long? ParentId); + +/// Nested category node for GET /categories?tree=true. +public sealed record CategoryTreeDto(long CategoryId, string Name, long? ParentId, IReadOnlyList Children); + +public sealed class CreateCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + public long? ParentId { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Common/PageQuery.cs b/Backend/ERPCore/Dtos/Common/PageQuery.cs new file mode 100644 index 0000000..639ce34 --- /dev/null +++ b/Backend/ERPCore/Dtos/Common/PageQuery.cs @@ -0,0 +1,37 @@ +namespace ERPCore.Dtos.Common; + +/// +/// Shared paging/sorting query binding (docs/11-BACKEND-PHASE1.md §1.5). Page size +/// is clamped to to enforce pagination bounds +/// (02-SECURITY B.6). Bind from the query string on list endpoints. +/// +public class PageQuery +{ + public const int MaxPageSize = 200; + public const int DefaultPageSize = 20; + + private int _page = 1; + private int _pageSize = DefaultPageSize; + + /// 1-based page number (default 1). + public int Page + { + get => _page; + set => _page = value < 1 ? 1 : value; + } + + /// Page size (default 20, clamped to 1..200). + public int PageSize + { + get => _pageSize; + set => _pageSize = value < 1 ? DefaultPageSize : Math.Min(value, MaxPageSize); + } + + /// Free-text search term (q). + public string? Q { get; set; } + + /// Sort spec, e.g. name or -createdAt. + public string? Sort { get; set; } + + public int Skip => (Page - 1) * PageSize; +} diff --git a/Backend/ERPCore/Dtos/Common/PagedResponse.cs b/Backend/ERPCore/Dtos/Common/PagedResponse.cs new file mode 100644 index 0000000..889fb97 --- /dev/null +++ b/Backend/ERPCore/Dtos/Common/PagedResponse.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Dtos.Common; + +/// +/// List envelope matching docs/11-BACKEND-PHASE1.md §1.4: +/// { "items": [...], "pagination": { page, pageSize, totalItems, totalPages } }. +/// +public sealed record PagedResponse(IReadOnlyList Items, PaginationDto Pagination) +{ + public static PagedResponse Create(IReadOnlyList items, int page, int pageSize, int totalItems) + { + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(totalItems / (double)pageSize); + return new PagedResponse(items, new PaginationDto(page, pageSize, totalItems, totalPages)); + } +} + +/// Pagination metadata block (docs/11 §1.4). +public sealed record PaginationDto(int Page, int PageSize, int TotalItems, int TotalPages); diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs new file mode 100644 index 0000000..c372cad --- /dev/null +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -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) -------------------------------- + +/// Row shape for GET /items. +public sealed record ItemListItemDto( + long ItemId, string Sku, string Name, long CategoryId, long BaseUomId, + long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, + string? TaxClass, EntityStatus Status); + +/// A single per-warehouse reorder policy row. +public sealed record ItemReorderDto(long WarehouseId, decimal ReorderPoint, decimal ReorderQty); + +/// Full item resource for GET /items/{id} and create/update responses. +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 Reorder, + DateTime CreatedAt, DateTime? UpdatedAt); + +/// UOM conversion row (docs/11 §2.2). +public sealed record UomConversionDto(long ConversionId, long FromUom, long ToUom, decimal Factor); + +/// Response body for PUT /items/{id}/uom-conversions. +public sealed record ItemUomConversionsDto(long ItemId, long BaseUomId, IReadOnlyList Conversions); + +/// Response body for PUT /items/{id}/reorder. +public sealed record ItemReorderSettingsDto(IReadOnlyList 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 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 Conversions { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Uoms/UomDtos.cs b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs new file mode 100644 index 0000000..90f5259 --- /dev/null +++ b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Uoms; + +/// UOM resource (docs/11-BACKEND-PHASE1.md §2.2). +public sealed record UomDto(long UomId, string Name); + +public sealed class CreateUomRequest +{ + [Required, StringLength(50)] public string Name { get; set; } = string.Empty; +} diff --git a/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs new file mode 100644 index 0000000..731099c --- /dev/null +++ b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Vendors; + +/// Vendor resource (docs/11-BACKEND-PHASE1.md §2.4). +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; } +} diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs new file mode 100644 index 0000000..ad91678 --- /dev/null +++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Warehouses; + +/// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). +public sealed record WarehouseDto(long WarehouseId, string Code, string Name); + +/// Bin/location resource (docs/11 §2.5). +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; } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BinConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BinConfiguration.cs new file mode 100644 index 0000000..89b5aa8 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BinConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs new file mode 100644 index 0000000..a0565cb --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs new file mode 100644 index 0000000..4a74f03 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().HasMaxLength(20).IsRequired(); + builder.Property(i => i.TrackingMode) + .HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(i => i.Status) + .HasConversion().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemReorderConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemReorderConfiguration.cs new file mode 100644 index 0000000..ceca4bf --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemReorderConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UomConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UomConfiguration.cs new file mode 100644 index 0000000..3425885 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UomConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("uoms"); + builder.HasKey(u => u.UomId); + + builder.Property(u => u.Name).IsRequired().HasMaxLength(50); + builder.HasIndex(u => u.Name).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs new file mode 100644 index 0000000..15445d4 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/VendorConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/VendorConfiguration.cs new file mode 100644 index 0000000..b8dffed --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/VendorConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/WarehouseConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/WarehouseConfiguration.cs new file mode 100644 index 0000000..acbcfad --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/WarehouseConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index e9368c9..e55b00f 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Entities; using Microsoft.EntityFrameworkCore; namespace ERPCore.Infra.Persistence; @@ -14,6 +15,16 @@ public class ErpDbContext : DbContext { } + // --- Master Data (docs/10 Part C.1) --- + public DbSet Categories => Set(); + public DbSet Uoms => Set(); + public DbSet UomConversions => Set(); + public DbSet Items => Set(); + public DbSet ItemReorders => Set(); + public DbSet Vendors => Set(); + public DbSet Warehouses => Set(); + public DbSet Bins => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs new file mode 100644 index 0000000..4d6fe75 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs @@ -0,0 +1,445 @@ +// +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 + { + /// + 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("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("bigint"); + + b.HasKey("CategoryId"); + + b.HasIndex("ParentId"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("bigint"); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("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("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("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("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("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("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.cs new file mode 100644 index 0000000..7ba3005 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.cs @@ -0,0 +1,325 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "categories", + columns: table => new + { + CategoryId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ParentId = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Terms = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TaxReg = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false, defaultValue: "LKR"), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Sku = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + CategoryId = table.Column(type: "bigint", nullable: false), + BaseUomId = table.Column(type: "bigint", nullable: false), + DefaultVendorId = table.Column(type: "bigint", nullable: true), + ItemType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TrackingMode = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TaxClass = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + WarehouseId = table.Column(type: "bigint", nullable: false), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + BinType = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "bigint", nullable: false), + WarehouseId = table.Column(type: "bigint", nullable: false), + ReorderPoint = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + ReorderQty = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "bigint", nullable: false), + FromUomId = table.Column(type: "bigint", nullable: false), + ToUomId = table.Column(type: "bigint", nullable: false), + Factor = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.Designer.cs new file mode 100644 index 0000000..ee80b14 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.Designer.cs @@ -0,0 +1,445 @@ +// +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 + { + /// + 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("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("bigint"); + + b.HasKey("CategoryId"); + + b.HasIndex("ParentId"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("bigint"); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("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("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("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("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("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("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.cs new file mode 100644 index 0000000..f83a4b3 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs new file mode 100644 index 0000000..c608573 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -0,0 +1,442 @@ +// +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("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("bigint"); + + b.HasKey("CategoryId"); + + b.HasIndex("ParentId"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("bigint"); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("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("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("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("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("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("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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 + } + } +} diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 054ad8c..ad5c3fc 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -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(o => @@ -36,6 +41,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); +// Master-data services (docs/11 §2) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Health checks (EF Core DB) builder.Services.AddHealthChecks().AddDbContextCheck(); diff --git a/Backend/ERPCore/Services/CategoryService.cs b/Backend/ERPCore/Services/CategoryService.cs new file mode 100644 index 0000000..178aa36 --- /dev/null +++ b/Backend/ERPCore/Services/CategoryService.cs @@ -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 _categories; + private readonly IUnitOfWork _uow; + + public CategoryService(IRepository categories, IUnitOfWork uow) + { + _categories = categories; + _uow = uow; + } + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task> 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 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 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); + } +} diff --git a/Backend/ERPCore/Services/Interfaces/ICategoryService.cs b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs new file mode 100644 index 0000000..c318ef6 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Categories; +using ERPCore.Dtos.Common; + +namespace ERPCore.Services.Interfaces; + +/// Category master business logic (docs/11-BACKEND-PHASE1.md §2.3). +public interface ICategoryService +{ + Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task> GetTreeAsync(CancellationToken ct = default); + Task CreateAsync(CreateCategoryRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IItemService.cs b/Backend/ERPCore/Services/Interfaces/IItemService.cs new file mode 100644 index 0000000..662db49 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IItemService.cs @@ -0,0 +1,28 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Items; + +namespace ERPCore.Services.Interfaces; + +/// +/// Item master business logic (docs/11-BACKEND-PHASE1.md §2.1–2.2). Returns DTOs; +/// entities never cross this boundary (00-CORE §4). +/// +public interface IItemService +{ + Task> ListAsync( + PageQuery query, EntityStatus? status, long? categoryId, TrackingMode? trackingMode, CancellationToken ct = default); + + Task?> GetAsync(long itemId, CancellationToken ct = default); + + Task> CreateAsync(CreateItemRequest request, CancellationToken ct = default); + + Task> UpdateAsync(long itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default); + + Task SetStatusAsync(long itemId, EntityStatus status, CancellationToken ct = default); + + Task UpdateReorderAsync(long itemId, UpdateReorderRequest request, CancellationToken ct = default); + + Task UpdateUomConversionsAsync(long itemId, UpdateUomConversionsRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IUomService.cs b/Backend/ERPCore/Services/Interfaces/IUomService.cs new file mode 100644 index 0000000..bb846f6 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IUomService.cs @@ -0,0 +1,11 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Uoms; + +namespace ERPCore.Services.Interfaces; + +/// UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2). +public interface IUomService +{ + Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task CreateAsync(CreateUomRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IVendorService.cs b/Backend/ERPCore/Services/Interfaces/IVendorService.cs new file mode 100644 index 0000000..9d2c59b --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IVendorService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Vendors; + +namespace ERPCore.Services.Interfaces; + +/// Vendor master business logic (docs/11-BACKEND-PHASE1.md §2.4). +public interface IVendorService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(long vendorId, CancellationToken ct = default); + Task> CreateAsync(CreateVendorRequest request, CancellationToken ct = default); + Task> UpdateAsync(long vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(long vendorId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IWarehouseService.cs b/Backend/ERPCore/Services/Interfaces/IWarehouseService.cs new file mode 100644 index 0000000..a1ab0ee --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IWarehouseService.cs @@ -0,0 +1,15 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Warehouses; + +namespace ERPCore.Services.Interfaces; + +/// Warehouse & bin master business logic (docs/11-BACKEND-PHASE1.md §2.5). +public interface IWarehouseService +{ + Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task GetAsync(long warehouseId, CancellationToken ct = default); + Task CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default); + + Task> ListBinsAsync(long warehouseId, CancellationToken ct = default); + Task CreateBinAsync(long warehouseId, CreateBinRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs new file mode 100644 index 0000000..487c65d --- /dev/null +++ b/Backend/ERPCore/Services/ItemService.cs @@ -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; + +/// +/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference +/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per +/// docs/11-BACKEND-PHASE1.md §2.1–2.2 and 02-SECURITY C.1. +/// +public sealed class ItemService : IItemService +{ + private readonly IRepository _items; + private readonly IRepository _categories; + private readonly IRepository _uoms; + private readonly IRepository _vendors; + private readonly IRepository _warehouses; + private readonly IUnitOfWork _uow; + + public ItemService( + IRepository items, + IRepository categories, + IRepository uoms, + IRepository vendors, + IRepository warehouses, + IUnitOfWork uow) + { + _items = items; + _categories = categories; + _uoms = uoms; + _vendors = vendors; + _warehouses = warehouses; + _uow = uow; + } + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> 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(ToDetail(item), item.RowVersion); + } + + public async Task> 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(ToDetail(item), item.RowVersion); + } + + public async Task> 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(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 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 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); +} diff --git a/Backend/ERPCore/Services/UomService.cs b/Backend/ERPCore/Services/UomService.cs new file mode 100644 index 0000000..4507a31 --- /dev/null +++ b/Backend/ERPCore/Services/UomService.cs @@ -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 _uoms; + private readonly IUnitOfWork _uow; + + public UomService(IRepository uoms, IUnitOfWork uow) + { + _uoms = uoms; + _uow = uow; + } + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task 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); + } +} diff --git a/Backend/ERPCore/Services/VendorService.cs b/Backend/ERPCore/Services/VendorService.cs new file mode 100644 index 0000000..7c5277d --- /dev/null +++ b/Backend/ERPCore/Services/VendorService.cs @@ -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 _vendors; + private readonly IUnitOfWork _uow; + + public VendorService(IRepository vendors, IUnitOfWork uow) + { + _vendors = vendors; + _uow = uow; + } + + public async Task> 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.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> 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(Map(vendor), vendor.RowVersion); + } + + public async Task> 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(Map(vendor), vendor.RowVersion); + } + + public async Task> 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(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); +} diff --git a/Backend/ERPCore/Services/WarehouseService.cs b/Backend/ERPCore/Services/WarehouseService.cs new file mode 100644 index 0000000..a77e82b --- /dev/null +++ b/Backend/ERPCore/Services/WarehouseService.cs @@ -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 _warehouses; + private readonly IRepository _bins; + private readonly IUnitOfWork _uow; + + public WarehouseService(IRepository warehouses, IRepository bins, IUnitOfWork uow) + { + _warehouses = warehouses; + _bins = bins; + _uow = uow; + } + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task 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 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> 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 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."); + } +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index ce28772..48a544b 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -3,12 +3,25 @@ namespace ERPCore.System.Errors; /// /// Stable domain error codes carried in the code 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. /// 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"; } diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index a190287..d8c99f7 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -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" diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index e215b62..627abc8 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -5,23 +5,24 @@ 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 +- [~] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created** (`Infra/Persistence/Migrations`, 8 master-data tables) — **not yet applied**: `dotnet ef database update` fails `28P01 password authentication failed for user "postgres"` (local Postgres is running on :5432 but the `postgres/postgres` dev creds in `appsettings.Development.json` don't match this box). Generated SQL script validates cleanly. **Action needed:** set the real local creds, then `ASPNETCORE_ENVIRONMENT=Development dotnet ef database update`. +- [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): entities, EF configs, DTOs, services, controllers — solution builds clean, app boots, and the generated OpenAPI exposes every path in `docs/11 §2`. Marked `[~]` (not `[x]`) because the **security gate** (00-CORE §8) is not yet fully met: the foundational auth control (02-SECURITY B.1) and the audit trail (B.3, the AR-01 compensating control) land in §6, and the schema is not yet applied to a DB. No live DB integration test has run (creds blocker above). Flip to `[x]` once §6 auth+audit are in and endpoints are exercised against Postgres. +- [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 @@ -47,6 +48,7 @@ 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`) @@ -61,3 +63,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done + +### 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` 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) apply migration — needs real local Postgres creds (see §0 note); (2) auth enforcement + audit trail — §6 (security gate for `[x]`); (3) no `DELETE` master endpoints — `MASTER_IN_USE` code reserved until transaction tables exist (deactivate-only per FR-MD-08).