From 4e84a15db7911ff4c767b832acc269a92629d011 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Mon, 13 Jul 2026 10:28:58 +0530 Subject: [PATCH] feat: Implement procurement services and number sequence management - Added INumberSequenceService interface for generating document numbers. - Created NumberSequenceService to handle atomic document number issuance. - Introduced IPurchaseOrderService interface and implemented PurchaseOrderService for managing purchase orders. - Added IRequisitionService interface and implemented RequisitionService for handling requisitions. - Created IRfqService interface and implemented RfqService for managing RFQs and vendor quotations. - Defined necessary DTOs and domain entities for procurement processes. - Ensured proper validation and error handling across services. --- .../Controllers/PurchaseOrdersController.cs | 74 ++ .../Controllers/RequisitionsController.cs | 44 + Backend/ERPCore/Controllers/RfqsController.cs | 49 + Backend/ERPCore/Domain/DocumentTypes.cs | 17 + .../ERPCore/Domain/Entities/NumberSequence.cs | 15 + Backend/ERPCore/Domain/Entities/PoLine.cs | 28 + .../ERPCore/Domain/Entities/PurchaseOrder.cs | 36 + .../ERPCore/Domain/Entities/Requisition.cs | 21 + .../Domain/Entities/RequisitionLine.cs | 16 + Backend/ERPCore/Domain/Entities/Rfq.cs | 22 + Backend/ERPCore/Domain/Entities/RfqLine.cs | 15 + Backend/ERPCore/Domain/Entities/User.cs | 20 + .../Domain/Entities/VendorQuotation.cs | 26 + .../Domain/Entities/VendorQuotationLine.cs | 16 + .../Domain/Enums/PurchaseOrderStatus.cs | 17 + .../ERPCore/Domain/Enums/RequisitionStatus.cs | 8 + Backend/ERPCore/Domain/Enums/RfqStatus.cs | 8 + .../Dtos/Procurement/PurchaseOrderDtos.cs | 52 + .../Dtos/Procurement/RequisitionDtos.cs | 29 + Backend/ERPCore/Dtos/Procurement/RfqDtos.cs | 50 + Backend/ERPCore/Infra/Auth/CurrentUser.cs | 3 + Backend/ERPCore/Infra/Auth/ICurrentUser.cs | 7 + .../NumberSequenceConfiguration.cs | 21 + .../PurchaseOrderConfiguration.cs | 76 ++ .../RequisitionConfiguration.cs | 49 + .../Configurations/RfqConfiguration.cs | 92 ++ .../Configurations/UserConfiguration.cs | 30 + .../ERPCore/Infra/Persistence/ErpDbContext.cs | 14 + .../20260710090753_AddProcurement.Designer.cs | 987 ++++++++++++++++++ .../20260710090753_AddProcurement.cs | 448 ++++++++ .../Migrations/ErpDbContextModelSnapshot.cs | 542 ++++++++++ Backend/ERPCore/Program.cs | 6 + .../Interfaces/INumberSequenceService.cs | 15 + .../Interfaces/IPurchaseOrderService.cs | 19 + .../Interfaces/IRequisitionService.cs | 13 + .../Services/Interfaces/IRfqService.cs | 12 + .../ERPCore/Services/NumberSequenceService.cs | 58 + .../ERPCore/Services/PurchaseOrderService.cs | 246 +++++ .../ERPCore/Services/RequisitionService.cs | 119 +++ Backend/ERPCore/Services/RfqService.cs | 157 +++ Backend/PROGRESS.md | 24 +- 41 files changed, 3495 insertions(+), 6 deletions(-) create mode 100644 Backend/ERPCore/Controllers/PurchaseOrdersController.cs create mode 100644 Backend/ERPCore/Controllers/RequisitionsController.cs create mode 100644 Backend/ERPCore/Controllers/RfqsController.cs create mode 100644 Backend/ERPCore/Domain/DocumentTypes.cs create mode 100644 Backend/ERPCore/Domain/Entities/NumberSequence.cs create mode 100644 Backend/ERPCore/Domain/Entities/PoLine.cs create mode 100644 Backend/ERPCore/Domain/Entities/PurchaseOrder.cs create mode 100644 Backend/ERPCore/Domain/Entities/Requisition.cs create mode 100644 Backend/ERPCore/Domain/Entities/RequisitionLine.cs create mode 100644 Backend/ERPCore/Domain/Entities/Rfq.cs create mode 100644 Backend/ERPCore/Domain/Entities/RfqLine.cs create mode 100644 Backend/ERPCore/Domain/Entities/User.cs create mode 100644 Backend/ERPCore/Domain/Entities/VendorQuotation.cs create mode 100644 Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs create mode 100644 Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/RequisitionStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/RfqStatus.cs create mode 100644 Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs create mode 100644 Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs create mode 100644 Backend/ERPCore/Dtos/Procurement/RfqDtos.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.Designer.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.cs create mode 100644 Backend/ERPCore/Services/Interfaces/INumberSequenceService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IRequisitionService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IRfqService.cs create mode 100644 Backend/ERPCore/Services/NumberSequenceService.cs create mode 100644 Backend/ERPCore/Services/PurchaseOrderService.cs create mode 100644 Backend/ERPCore/Services/RequisitionService.cs create mode 100644 Backend/ERPCore/Services/RfqService.cs diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs new file mode 100644 index 0000000..03c9524 --- /dev/null +++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs @@ -0,0 +1,74 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Purchase-order endpoints (docs/11 §3.3). +[Route("api/v1/purchase-orders")] +public sealed class PurchaseOrdersController : ApiControllerBase +{ + private readonly IPurchaseOrderService _pos; + + public PurchaseOrdersController(IPurchaseOrderService pos) => _pos = pos; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] long? vendorId, CancellationToken ct) + => Ok(await _pos.ListAsync(query, status, vendorId, ct)); + + [HttpGet("{poId:long}")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(long poId, CancellationToken ct) + { + var result = await _pos.GetAsync(poId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side. + [HttpPost] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreatePurchaseOrderRequest request, CancellationToken ct) + { + var result = await _pos.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/purchase-orders/{result.Value.PoId}", result.Value); + } + + /// Edit while open (FR-PROC-05); requires If-Match. 409 PO_NOT_EDITABLE if closed. + [HttpPut("{poId:long}")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(long poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _pos.UpdateAsync(poId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled. + [HttpPost("{poId:long}/approve")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Approve(long poId, CancellationToken ct) + => Ok(await _pos.ApproveAsync(poId, ct)); + + /// Cancel — 409 if any goods have been received against the PO. + [HttpPost("{poId:long}/cancel")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Cancel(long poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct) + => Ok(await _pos.CancelAsync(poId, request.Reason, ct)); +} diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs new file mode 100644 index 0000000..10d4c08 --- /dev/null +++ b/Backend/ERPCore/Controllers/RequisitionsController.cs @@ -0,0 +1,44 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Purchase-requisition endpoints (docs/11 §3.1). +[Route("api/v1/requisitions")] +public sealed class RequisitionsController : ApiControllerBase +{ + private readonly IRequisitionService _requisitions; + + public RequisitionsController(IRequisitionService requisitions) => _requisitions = requisitions; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _requisitions.ListAsync(query, ct)); + + [HttpGet("{requisitionId:long}")] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(long requisitionId, CancellationToken ct) + { + var dto = await _requisitions.GetAsync(requisitionId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateRequisitionRequest request, CancellationToken ct) + { + var dto = await _requisitions.CreateAsync(request, ct); + return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto); + } + + [HttpPost("{requisitionId:long}/submit")] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Submit(long requisitionId, CancellationToken ct) + => Ok(await _requisitions.SubmitAsync(requisitionId, ct)); +} diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs new file mode 100644 index 0000000..736ddbc --- /dev/null +++ b/Backend/ERPCore/Controllers/RfqsController.cs @@ -0,0 +1,49 @@ +using ERPCore.Dtos.Procurement; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// RFQ & quotation endpoints (docs/11 §3.2). +[Route("api/v1/rfqs")] +public sealed class RfqsController : ApiControllerBase +{ + private readonly IRfqService _rfqs; + + public RfqsController(IRfqService rfqs) => _rfqs = rfqs; + + [HttpGet("{rfqId:long}")] + [ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(long rfqId, CancellationToken ct) + { + var dto = await _rfqs.GetAsync(rfqId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + [ProducesResponseType(typeof(RfqDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateRfqRequest request, CancellationToken ct) + { + var dto = await _rfqs.CreateAsync(request, ct); + return Created($"/api/v1/rfqs/{dto.RfqId}", dto); + } + + [HttpPost("{rfqId:long}/quotations")] + [ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> AddQuotation(long rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct) + { + var dto = await _rfqs.AddQuotationAsync(rfqId, request, ct); + return Created($"/api/v1/rfqs/{rfqId}/quotations/{dto.QuotationId}", dto); + } + + [HttpGet("{rfqId:long}/comparison")] + [ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Comparison(long rfqId, CancellationToken ct) + => Ok(await _rfqs.GetComparisonAsync(rfqId, ct)); +} diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs new file mode 100644 index 0000000..0a84d94 --- /dev/null +++ b/Backend/ERPCore/Domain/DocumentTypes.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Domain; + +/// +/// Document-type prefixes for and the +/// generated document numbers (docs/10 §B.8.2). One prefix per numbered document. +/// +public static class DocumentTypes +{ + public const string Requisition = "PR"; + public const string Rfq = "RFQ"; + public const string PurchaseOrder = "PO"; + public const string Grn = "GRN"; + public const string Transfer = "TRF"; + public const string Adjustment = "ADJ"; + public const string Count = "CNT"; + public const string PurchaseReturn = "PRET"; +} diff --git a/Backend/ERPCore/Domain/Entities/NumberSequence.cs b/Backend/ERPCore/Domain/Entities/NumberSequence.cs new file mode 100644 index 0000000..6db8de6 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/NumberSequence.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Per-document-type, per-year running counter behind human document numbers +/// (FR-X-03): PR-2026-00001, PO-2026-00042, … Numbers are issued +/// inside the document's transaction so they are unique and gap-controlled. +/// Model: docs/10 Part C.7. +/// +public class NumberSequence +{ + public long SequenceId { get; set; } + public string DocType { get; set; } = string.Empty; + public int Year { get; set; } + public long LastNumber { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs new file mode 100644 index 0000000..b564499 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PoLine.cs @@ -0,0 +1,28 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Purchase-order line (FR-PROC-03). is the line tax rate +/// (e.g. 0.18); accrues as GRNs confirm (FR-PROC-07). +/// Model: docs/10 Part C.2. +/// +public class PoLine +{ + public long PoLineId { get; set; } + + public long PoId { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public long UomId { get; set; } + public Uom? Uom { get; set; } + + public long WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public decimal Qty { get; set; } + public decimal UnitPrice { get; set; } + public decimal Tax { get; set; } + public decimal QtyReceived { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs new file mode 100644 index 0000000..257f67d --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs @@ -0,0 +1,36 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a +/// ETag token; editable while open (FR-PROC-05). +/// Phase 1 auto-approves on creation; is retained +/// for the future approval workflow. Totals are computed server-side from lines +/// (not stored). Model: docs/10 Part C.2. +/// +public class PurchaseOrder +{ + public long PoId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long VendorId { get; set; } + public Vendor? Vendor { get; set; } + + public long? RequisitionId { get; set; } + public Requisition? Requisition { get; set; } + + public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft; + public bool ApprovalRequired { get; set; } + + public long CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/Requisition.cs b/Backend/ERPCore/Domain/Entities/Requisition.cs new file mode 100644 index 0000000..e6cfda6 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Requisition.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Purchase requisition header (FR-PROC-01). is the audit +/// actor from the token (never the body). Model: docs/10 Part C.2. +/// +public class Requisition +{ + public long RequisitionId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long RequestedBy { get; set; } + public User? Requester { get; set; } + + public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft; + public DateTime CreatedAt { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/RequisitionLine.cs b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs new file mode 100644 index 0000000..13afc08 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// Requisition line (FR-PROC-01). Model: docs/10 Part C.2. +public class RequisitionLine +{ + public long ReqLineId { get; set; } + + public long RequisitionId { get; set; } + public Requisition? Requisition { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public decimal Qty { get; set; } + public DateOnly? RequiredBy { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Rfq.cs b/Backend/ERPCore/Domain/Entities/Rfq.cs new file mode 100644 index 0000000..e7177fd --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Rfq.cs @@ -0,0 +1,22 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Request for Quotation header (FR-PROC-02) raised from a requisition. Vendor +/// quotations attach for comparison. Model: docs/10 Part C.2. +/// +public class Rfq +{ + public long RfqId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long RequisitionId { get; set; } + public Requisition? Requisition { get; set; } + + public RfqStatus Status { get; set; } = RfqStatus.Open; + public DateTime CreatedAt { get; set; } + + public ICollection Lines { get; set; } = new List(); + public ICollection Quotations { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/RfqLine.cs b/Backend/ERPCore/Domain/Entities/RfqLine.cs new file mode 100644 index 0000000..a91191b --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/RfqLine.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Domain.Entities; + +/// RFQ line — an item + quantity being quoted (FR-PROC-02). Model: docs/10 Part C.2. +public class RfqLine +{ + public long RfqLineId { get; set; } + + public long RfqId { get; set; } + public Rfq? Rfq { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public decimal Qty { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs new file mode 100644 index 0000000..24b58db --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/User.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Application user (FR-X-01). In Phase 1 authentication/RBAC are deferred; this +/// table exists so mutations can be stamped with an audit actor and documents can +/// carry a `createdBy`/`requestedBy` FK. A seeded system user (id 1) is the +/// fallback actor until `/auth/login` lands (§6). Model: docs/10 Part C.7. +/// +public class User +{ + /// Seeded fallback actor used while auth is deferred. + public const long SystemUserId = 1; + + public long UserId { get; set; } + public string Username { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; +} diff --git a/Backend/ERPCore/Domain/Entities/VendorQuotation.cs b/Backend/ERPCore/Domain/Entities/VendorQuotation.cs new file mode 100644 index 0000000..5eab1f4 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/VendorQuotation.cs @@ -0,0 +1,26 @@ +namespace ERPCore.Domain.Entities; + +/// +/// A vendor's quotation against an RFQ (FR-PROC-02). Per-item pricing lives in +/// . +/// +/// Deviation note: docs/10 Part C.2 models VENDOR_QUOTATION with scalar +/// unit_price/lead_days and no item reference, which cannot represent +/// the per-line pricing the API contract requires (docs/11 §3.2). This header + +/// split follows the authoritative API shape. +/// +/// +public class VendorQuotation +{ + public long QuotationId { get; set; } + + public long RfqId { get; set; } + public Rfq? Rfq { get; set; } + + public long VendorId { get; set; } + public Vendor? Vendor { get; set; } + + public DateTime CreatedAt { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs new file mode 100644 index 0000000..6880524 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// Per-item quoted price and lead time within a (docs/11 §3.2). +public class VendorQuotationLine +{ + public long QuotationLineId { get; set; } + + public long QuotationId { get; set; } + public VendorQuotation? Quotation { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public decimal UnitPrice { get; set; } + public int LeadDays { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs b/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs new file mode 100644 index 0000000..9cb7c49 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Phase 1 auto-approves on +/// creation, so is reserved (not entered) until the +/// approval workflow is enabled (FR-PROC-04). Stored as a string. +/// +public enum PurchaseOrderStatus +{ + Draft, + PendingApproval, + Approved, + PartiallyReceived, + FullyReceived, + Closed, + Cancelled +} diff --git a/Backend/ERPCore/Domain/Enums/RequisitionStatus.cs b/Backend/ERPCore/Domain/Enums/RequisitionStatus.cs new file mode 100644 index 0000000..8e9d65e --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/RequisitionStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Purchase-requisition lifecycle (docs/11 §3.1; docs/10 §B.8.1). Stored as a string. +public enum RequisitionStatus +{ + Draft, + Submitted +} diff --git a/Backend/ERPCore/Domain/Enums/RfqStatus.cs b/Backend/ERPCore/Domain/Enums/RfqStatus.cs new file mode 100644 index 0000000..89c85b8 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/RfqStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// RFQ lifecycle (docs/11 §3.2). Stored as a string. +public enum RfqStatus +{ + Open, + Closed +} diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs new file mode 100644 index 0000000..2c372c8 --- /dev/null +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Procurement; + +// Responses (docs/11 §3.3) ------------------------------------------------------ + +public sealed record PoLineDto( + long PoLineId, long ItemId, long UomId, long WarehouseId, + decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived); + +public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency); + +public sealed record PurchaseOrderDto( + long PoId, string DocNo, long VendorId, long? RequisitionId, PurchaseOrderStatus Status, + bool ApprovalRequired, long CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt, + PoTotalsDto Totals, IReadOnlyList Lines); + +public sealed record PurchaseOrderSummaryDto( + long PoId, string DocNo, long VendorId, PurchaseOrderStatus Status, + bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals); + +// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals + +public sealed class CreatePoLineInput +{ + [Required] public long ItemId { get; set; } + [Required] public long UomId { get; set; } + [Required] public long WarehouseId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + [Range(0, double.MaxValue)] public decimal UnitPrice { get; set; } + [Range(0, 1)] public decimal Tax { get; set; } +} + +public sealed class CreatePurchaseOrderRequest +{ + [Required] public long VendorId { get; set; } + public long? RequisitionId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class UpdatePurchaseOrderRequest +{ + [Required] public long VendorId { get; set; } + public long? RequisitionId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class CancelPurchaseOrderRequest +{ + [StringLength(500)] public string? Reason { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs new file mode 100644 index 0000000..d9af44f --- /dev/null +++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Procurement; + +// Responses (docs/11 §3.1) ------------------------------------------------------ + +public sealed record RequisitionLineDto(long ReqLineId, long ItemId, decimal Qty, DateOnly? RequiredBy); + +public sealed record RequisitionDto( + long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy, + DateTime CreatedAt, IReadOnlyList Lines); + +public sealed record RequisitionSummaryDto( + long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy, DateTime CreatedAt); + +// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ---- + +public sealed class CreateRequisitionLineInput +{ + [Required] public long ItemId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + public DateOnly? RequiredBy { get; set; } +} + +public sealed class CreateRequisitionRequest +{ + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs new file mode 100644 index 0000000..8109941 --- /dev/null +++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Procurement; + +// Responses (docs/11 §3.2) ------------------------------------------------------ + +public sealed record RfqLineDto(long RfqLineId, long ItemId, decimal Qty); + +public sealed record RfqDto( + long RfqId, string DocNo, long RequisitionId, RfqStatus Status, IReadOnlyList Lines); + +public sealed record QuotationLineDto(long ItemId, decimal UnitPrice, int LeadDays); + +public sealed record VendorQuotationDto( + long QuotationId, long RfqId, long VendorId, IReadOnlyList Lines); + +/// Per-item, per-vendor price matrix for GET /rfqs/{id}/comparison. +public sealed record RfqComparisonCellDto(long VendorId, long QuotationId, decimal UnitPrice, int LeadDays); +public sealed record RfqComparisonRowDto(long ItemId, decimal Qty, IReadOnlyList Quotes); +public sealed record RfqComparisonDto(long RfqId, IReadOnlyList VendorIds, IReadOnlyList Rows); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreateRfqLineInput +{ + [Required] public long ItemId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class CreateRfqRequest +{ + [Required] public long RequisitionId { get; set; } + /// Vendors the RFQ is issued to (validated for existence; quotations reference them). + public List VendorIds { get; set; } = new(); + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class CreateQuotationLineInput +{ + [Required] public long ItemId { get; set; } + [Range(0, double.MaxValue)] public decimal UnitPrice { get; set; } + [Range(0, int.MaxValue)] public int LeadDays { get; set; } +} + +public sealed class CreateQuotationRequest +{ + [Required] public long VendorId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Infra/Auth/CurrentUser.cs b/Backend/ERPCore/Infra/Auth/CurrentUser.cs index 780f200..06ff7ba 100644 --- a/Backend/ERPCore/Infra/Auth/CurrentUser.cs +++ b/Backend/ERPCore/Infra/Auth/CurrentUser.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ERPCore.Domain.Entities; namespace ERPCore.Infra.Auth; @@ -28,4 +29,6 @@ public sealed class CurrentUser : ICurrentUser return string.IsNullOrWhiteSpace(sub) ? SystemActor : sub; } } + + public long AuditUserId => long.TryParse(UserId, out var id) ? id : User.SystemUserId; } diff --git a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs index ad7860d..be7317f 100644 --- a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs +++ b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs @@ -10,6 +10,13 @@ public interface ICurrentUser /// The audit actor identity (token `sub`), or "system" when unauthenticated. string UserId { get; } + /// + /// Numeric audit actor for stamping document createdBy/requestedBy FKs. + /// Resolves the token sub to a user id; falls back to the seeded system + /// user () while auth is deferred (§6). + /// + long AuditUserId { get; } + /// True when the request carries an authenticated principal. bool IsAuthenticated { get; } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs new file mode 100644 index 0000000..45282a2 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class NumberSequenceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("number_sequences"); + builder.HasKey(s => s.SequenceId); + + builder.Property(s => s.DocType).HasColumnName("doc_type").IsRequired().HasMaxLength(10); + builder.Property(s => s.Year).HasColumnName("year").IsRequired(); + builder.Property(s => s.LastNumber).HasColumnName("last_number").IsRequired(); + + // One counter per (doc type, year); also the ON CONFLICT target for atomic issue. + builder.HasIndex(s => new { s.DocType, s.Year }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs new file mode 100644 index 0000000..8d452e0 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs @@ -0,0 +1,76 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("purchase_orders"); + builder.HasKey(p => p.PoId); + + builder.Property(p => p.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(p => p.DocNo).IsUnique(); + + builder.Property(p => p.Status) + .HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(p => p.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(p => p.RowVersion).IsRowVersion(); + + builder.HasOne(p => p.Vendor) + .WithMany() + .HasForeignKey(p => p.VendorId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(p => p.Requisition) + .WithMany() + .HasForeignKey(p => p.RequisitionId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(p => p.Creator) + .WithMany() + .HasForeignKey(p => p.CreatedBy) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(p => p.Status); + builder.HasIndex(p => p.VendorId); + } +} + +public sealed class PoLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("po_lines"); + builder.HasKey(l => l.PoLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + builder.Property(l => l.UnitPrice).HasPrecision(18, 4); + builder.Property(l => l.Tax).HasPrecision(9, 4); + builder.Property(l => l.QtyReceived).HasPrecision(18, 4); + + builder.HasOne(l => l.PurchaseOrder) + .WithMany(p => p.Lines) + .HasForeignKey(l => l.PoId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Item) + .WithMany() + .HasForeignKey(l => l.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(l => l.Uom) + .WithMany() + .HasForeignKey(l => l.UomId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(l => l.Warehouse) + .WithMany() + .HasForeignKey(l => l.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs new file mode 100644 index 0000000..492d786 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs @@ -0,0 +1,49 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class RequisitionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("requisitions"); + builder.HasKey(r => r.RequisitionId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + + builder.Property(r => r.Status) + .HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.CreatedAt).IsRequired(); + + builder.HasOne(r => r.Requester) + .WithMany() + .HasForeignKey(r => r.RequestedBy) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(r => r.Status); + } +} + +public sealed class RequisitionLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("requisition_lines"); + builder.HasKey(l => l.ReqLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + + builder.HasOne(l => l.Requisition) + .WithMany(r => r.Lines) + .HasForeignKey(l => l.RequisitionId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Item) + .WithMany() + .HasForeignKey(l => l.ItemId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs new file mode 100644 index 0000000..6a95ef3 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs @@ -0,0 +1,92 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class RfqConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("rfqs"); + builder.HasKey(r => r.RfqId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + + builder.Property(r => r.Status) + .HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.CreatedAt).IsRequired(); + + builder.HasOne(r => r.Requisition) + .WithMany() + .HasForeignKey(r => r.RequisitionId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class RfqLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("rfq_lines"); + builder.HasKey(l => l.RfqLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + + builder.HasOne(l => l.Rfq) + .WithMany(r => r.Lines) + .HasForeignKey(l => l.RfqId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Item) + .WithMany() + .HasForeignKey(l => l.ItemId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class VendorQuotationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("vendor_quotations"); + builder.HasKey(q => q.QuotationId); + + builder.Property(q => q.CreatedAt).IsRequired(); + + builder.HasOne(q => q.Rfq) + .WithMany(r => r.Quotations) + .HasForeignKey(q => q.RfqId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(q => q.Vendor) + .WithMany() + .HasForeignKey(q => q.VendorId) + .OnDelete(DeleteBehavior.Restrict); + + // One quotation per vendor per RFQ. + builder.HasIndex(q => new { q.RfqId, q.VendorId }).IsUnique(); + } +} + +public sealed class VendorQuotationLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("vendor_quotation_lines"); + builder.HasKey(l => l.QuotationLineId); + + builder.Property(l => l.UnitPrice).HasPrecision(18, 4); + + builder.HasOne(l => l.Quotation) + .WithMany(q => q.Lines) + .HasForeignKey(l => l.QuotationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Item) + .WithMany() + .HasForeignKey(l => l.ItemId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..7a1c48b --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,30 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + builder.HasKey(u => u.UserId); + + builder.Property(u => u.Username).IsRequired().HasMaxLength(100); + builder.HasIndex(u => u.Username).IsUnique(); + builder.Property(u => u.DisplayName).IsRequired().HasMaxLength(200); + builder.Property(u => u.Status) + .HasConversion().HasMaxLength(20).IsRequired(); + + // Seeded fallback audit actor while auth is deferred (§6). + builder.HasData(new User + { + UserId = User.SystemUserId, + Username = "system", + DisplayName = "System", + Status = EntityStatus.Active + }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index e55b00f..d8bf5d5 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -25,6 +25,20 @@ public class ErpDbContext : DbContext public DbSet Warehouses => Set(); public DbSet Bins => Set(); + // --- Cross-cutting (docs/10 Part C.7) --- + public DbSet Users => Set(); + public DbSet NumberSequences => Set(); + + // --- Procurement (docs/10 Part C.2) --- + public DbSet Requisitions => Set(); + public DbSet RequisitionLines => Set(); + public DbSet Rfqs => Set(); + public DbSet RfqLines => Set(); + public DbSet VendorQuotations => Set(); + public DbSet VendorQuotationLines => Set(); + public DbSet PurchaseOrders => Set(); + public DbSet PoLines => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.Designer.cs new file mode 100644 index 0000000..9ce865a --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.Designer.cs @@ -0,0 +1,987 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20260710090753_AddProcurement")] + partial class AddProcurement + { + /// + 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.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("bigint") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("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.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1L, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("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.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("bigint"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("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.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.cs new file mode 100644 index 0000000..ee2d903 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.cs @@ -0,0 +1,448 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class AddProcurement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "number_sequences", + columns: table => new + { + SequenceId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + doc_type = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + year = table.Column(type: "integer", nullable: false), + last_number = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_number_sequences", x => x.SequenceId); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.UserId); + }); + + migrationBuilder.CreateTable( + name: "requisitions", + columns: table => new + { + RequisitionId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + RequestedBy = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_requisitions", x => x.RequisitionId); + table.ForeignKey( + name: "FK_requisitions_users_RequestedBy", + column: x => x.RequestedBy, + principalTable: "users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "purchase_orders", + columns: table => new + { + PoId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + VendorId = table.Column(type: "bigint", nullable: false), + RequisitionId = table.Column(type: "bigint", nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ApprovalRequired = table.Column(type: "boolean", nullable: false), + CreatedBy = table.Column(type: "bigint", nullable: false), + 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_purchase_orders", x => x.PoId); + table.ForeignKey( + name: "FK_purchase_orders_requisitions_RequisitionId", + column: x => x.RequisitionId, + principalTable: "requisitions", + principalColumn: "RequisitionId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_purchase_orders_users_CreatedBy", + column: x => x.CreatedBy, + principalTable: "users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_purchase_orders_vendors_VendorId", + column: x => x.VendorId, + principalTable: "vendors", + principalColumn: "VendorId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "requisition_lines", + columns: table => new + { + ReqLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RequisitionId = table.Column(type: "bigint", nullable: false), + ItemId = table.Column(type: "bigint", nullable: false), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + RequiredBy = table.Column(type: "date", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_requisition_lines", x => x.ReqLineId); + table.ForeignKey( + name: "FK_requisition_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requisition_lines_requisitions_RequisitionId", + column: x => x.RequisitionId, + principalTable: "requisitions", + principalColumn: "RequisitionId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "rfqs", + columns: table => new + { + RfqId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + RequisitionId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_rfqs", x => x.RfqId); + table.ForeignKey( + name: "FK_rfqs_requisitions_RequisitionId", + column: x => x.RequisitionId, + principalTable: "requisitions", + principalColumn: "RequisitionId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "po_lines", + columns: table => new + { + PoLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PoId = table.Column(type: "bigint", nullable: false), + ItemId = table.Column(type: "bigint", nullable: false), + UomId = table.Column(type: "bigint", nullable: false), + WarehouseId = table.Column(type: "bigint", nullable: false), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitPrice = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Tax = table.Column(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false), + QtyReceived = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_po_lines", x => x.PoLineId); + table.ForeignKey( + name: "FK_po_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_po_lines_purchase_orders_PoId", + column: x => x.PoId, + principalTable: "purchase_orders", + principalColumn: "PoId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_po_lines_uoms_UomId", + column: x => x.UomId, + principalTable: "uoms", + principalColumn: "UomId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_po_lines_warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "warehouses", + principalColumn: "WarehouseId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "rfq_lines", + columns: table => new + { + RfqLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RfqId = table.Column(type: "bigint", nullable: false), + ItemId = table.Column(type: "bigint", nullable: false), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_rfq_lines", x => x.RfqLineId); + table.ForeignKey( + name: "FK_rfq_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_rfq_lines_rfqs_RfqId", + column: x => x.RfqId, + principalTable: "rfqs", + principalColumn: "RfqId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "vendor_quotations", + columns: table => new + { + QuotationId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RfqId = table.Column(type: "bigint", nullable: false), + VendorId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_vendor_quotations", x => x.QuotationId); + table.ForeignKey( + name: "FK_vendor_quotations_rfqs_RfqId", + column: x => x.RfqId, + principalTable: "rfqs", + principalColumn: "RfqId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_vendor_quotations_vendors_VendorId", + column: x => x.VendorId, + principalTable: "vendors", + principalColumn: "VendorId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "vendor_quotation_lines", + columns: table => new + { + QuotationLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + QuotationId = table.Column(type: "bigint", nullable: false), + ItemId = table.Column(type: "bigint", nullable: false), + UnitPrice = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + LeadDays = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_vendor_quotation_lines", x => x.QuotationLineId); + table.ForeignKey( + name: "FK_vendor_quotation_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_vendor_quotation_lines_vendor_quotations_QuotationId", + column: x => x.QuotationId, + principalTable: "vendor_quotations", + principalColumn: "QuotationId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "users", + columns: new[] { "UserId", "DisplayName", "Status", "Username" }, + values: new object[] { 1L, "System", "Active", "system" }); + + migrationBuilder.CreateIndex( + name: "IX_number_sequences_doc_type_year", + table: "number_sequences", + columns: new[] { "doc_type", "year" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_ItemId", + table: "po_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_PoId", + table: "po_lines", + column: "PoId"); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_UomId", + table: "po_lines", + column: "UomId"); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_WarehouseId", + table: "po_lines", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_CreatedBy", + table: "purchase_orders", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_DocNo", + table: "purchase_orders", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_RequisitionId", + table: "purchase_orders", + column: "RequisitionId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_Status", + table: "purchase_orders", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_VendorId", + table: "purchase_orders", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_requisition_lines_ItemId", + table: "requisition_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_requisition_lines_RequisitionId", + table: "requisition_lines", + column: "RequisitionId"); + + migrationBuilder.CreateIndex( + name: "IX_requisitions_DocNo", + table: "requisitions", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_requisitions_RequestedBy", + table: "requisitions", + column: "RequestedBy"); + + migrationBuilder.CreateIndex( + name: "IX_requisitions_Status", + table: "requisitions", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_rfq_lines_ItemId", + table: "rfq_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_rfq_lines_RfqId", + table: "rfq_lines", + column: "RfqId"); + + migrationBuilder.CreateIndex( + name: "IX_rfqs_DocNo", + table: "rfqs", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_rfqs_RequisitionId", + table: "rfqs", + column: "RequisitionId"); + + migrationBuilder.CreateIndex( + name: "IX_users_Username", + table: "users", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotation_lines_ItemId", + table: "vendor_quotation_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotation_lines_QuotationId", + table: "vendor_quotation_lines", + column: "QuotationId"); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotations_RfqId_VendorId", + table: "vendor_quotations", + columns: new[] { "RfqId", "VendorId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotations_VendorId", + table: "vendor_quotations", + column: "VendorId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "number_sequences"); + + migrationBuilder.DropTable( + name: "po_lines"); + + migrationBuilder.DropTable( + name: "requisition_lines"); + + migrationBuilder.DropTable( + name: "rfq_lines"); + + migrationBuilder.DropTable( + name: "vendor_quotation_lines"); + + migrationBuilder.DropTable( + name: "purchase_orders"); + + migrationBuilder.DropTable( + name: "vendor_quotations"); + + migrationBuilder.DropTable( + name: "rfqs"); + + migrationBuilder.DropTable( + name: "requisitions"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index c608573..bf2c3f1 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -185,6 +185,270 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("item_reorders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("bigint") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { b.Property("UomId") @@ -239,6 +503,46 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("uom_conversions", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1L, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => { b.Property("VendorId") @@ -301,6 +605,63 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("vendors", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("bigint"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => { b.Property("WarehouseId") @@ -393,6 +754,127 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") @@ -420,6 +902,44 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("ToUom"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => { b.Navigation("Children"); @@ -432,6 +952,28 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("UomConversions"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => { b.Navigation("Bins"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index ad5c3fc..73b5f1a 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -48,6 +48,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Cross-cutting + procurement services (docs/11 §3) +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/Interfaces/INumberSequenceService.cs b/Backend/ERPCore/Services/Interfaces/INumberSequenceService.cs new file mode 100644 index 0000000..5be5af5 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/INumberSequenceService.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Services.Interfaces; + +/// +/// Issues gap-controlled, per-year document numbers (FR-X-03). Call inside the +/// document's UoW transaction so the reserved number rolls back with the document +/// on failure. +/// +public interface INumberSequenceService +{ + /// + /// Reserve and return the next number for in the + /// current year, formatted e.g. PO-2026-00042. + /// + Task NextAsync(string docType, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs new file mode 100644 index 0000000..b430a03 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs @@ -0,0 +1,19 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; + +namespace ERPCore.Services.Interfaces; + +/// Purchase-order business logic (docs/11 §3.3; FR-PROC-03..07). +public interface IPurchaseOrderService +{ + Task> ListAsync( + PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default); + + Task?> GetAsync(long poId, CancellationToken ct = default); + Task> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default); + Task> UpdateAsync(long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task ApproveAsync(long poId, CancellationToken ct = default); + Task CancelAsync(long poId, string? reason, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs new file mode 100644 index 0000000..5c708db --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs @@ -0,0 +1,13 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; + +namespace ERPCore.Services.Interfaces; + +/// Purchase-requisition business logic (docs/11 §3.1). +public interface IRequisitionService +{ + Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task GetAsync(long requisitionId, CancellationToken ct = default); + Task CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default); + Task SubmitAsync(long requisitionId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IRfqService.cs b/Backend/ERPCore/Services/Interfaces/IRfqService.cs new file mode 100644 index 0000000..bdeef9a --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IRfqService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Procurement; + +namespace ERPCore.Services.Interfaces; + +/// RFQ & vendor-quotation business logic (docs/11 §3.2). +public interface IRfqService +{ + Task GetAsync(long rfqId, CancellationToken ct = default); + Task CreateAsync(CreateRfqRequest request, CancellationToken ct = default); + Task AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default); + Task GetComparisonAsync(long rfqId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/NumberSequenceService.cs b/Backend/ERPCore/Services/NumberSequenceService.cs new file mode 100644 index 0000000..c48d349 --- /dev/null +++ b/Backend/ERPCore/Services/NumberSequenceService.cs @@ -0,0 +1,58 @@ +using System.Data; +using ERPCore.Infra.Persistence; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ERPCore.Services; + +/// +/// Atomic document-number issuer. Uses a single INSERT … ON CONFLICT … DO UPDATE +/// … RETURNING so concurrent issues for the same (docType, year) cannot get the +/// same number (the row is locked for the duration of the upsert). Runs as a direct +/// ADO.NET command enlisted in the DbContext's current transaction so it commits or +/// rolls back with the document (FR-X-03). EF's SqlQuery is avoided here +/// because it wraps the statement in a subquery, which PostgreSQL disallows for a +/// data-modifying statement. +/// +public sealed class NumberSequenceService : INumberSequenceService +{ + private readonly ErpDbContext _db; + + public NumberSequenceService(ErpDbContext db) => _db = db; + + public async Task NextAsync(string docType, CancellationToken ct = default) + { + var year = DateTime.UtcNow.Year; + + var conn = _db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + await conn.OpenAsync(ct); + + await using var cmd = conn.CreateCommand(); + cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction(); + cmd.CommandText = """ + INSERT INTO number_sequences (doc_type, year, last_number) + VALUES (@docType, @year, 1) + ON CONFLICT (doc_type, year) + DO UPDATE SET last_number = number_sequences.last_number + 1 + RETURNING last_number; + """; + AddParam(cmd, "docType", docType); + AddParam(cmd, "year", year); + + var result = await cmd.ExecuteScalarAsync(ct) + ?? throw new InvalidOperationException($"Failed to issue a document number for '{docType}'."); + var next = Convert.ToInt64(result); + + return $"{docType}-{year}-{next:D5}"; + } + + private static void AddParam(IDbCommand cmd, string name, object value) + { + var p = cmd.CreateParameter(); + p.ParameterName = name; + p.Value = value; + cmd.Parameters.Add(p); + } +} diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs new file mode 100644 index 0000000..9b74f93 --- /dev/null +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -0,0 +1,246 @@ +using ERPCore.Common.Http; +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Purchase-order service. Phase 1 auto-approves on creation (FR-PROC-04), +/// PO is freely editable while open (FR-PROC-05), totals are computed server-side +/// (02-SECURITY C.2), and cancel is blocked once any receipt exists. +/// +public sealed class PurchaseOrderService : IPurchaseOrderService +{ + private const string BaseCurrency = "LKR"; + + private readonly IRepository _pos; + private readonly IRepository _vendors; + private readonly IRepository _requisitions; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public PurchaseOrderService( + IRepository pos, IRepository vendors, IRepository requisitions, + IRepository items, IRepository uoms, IRepository warehouses, + INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + { + _pos = pos; + _vendors = vendors; + _requisitions = requisitions; + _items = items; + _uoms = uoms; + _warehouses = warehouses; + _numbers = numbers; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default) + { + var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(p => EF.Functions.ILike(p.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(p => p.Status == status); + if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(p => p.PoId) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + var summaries = rows.Select(p => new PurchaseOrderSummaryDto( + p.PoId, p.DocNo, p.VendorId, p.Status, p.ApprovalRequired, p.CreatedAt, ComputeTotals(p.Lines))).ToList(); + + return PagedResponse.Create(summaries, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(long poId, CancellationToken ct = default) + { + var po = await _pos.Query().AsNoTracking() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct); + return po is null ? null : new ETagged(Map(po), po.RowVersion); + } + + public async Task> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default) + { + await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct); + var actor = _currentUser.AuditUserId; + + var po = await _uow.ExecuteInTransactionAsync(async token => + { + var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseOrder, token); + var entity = new PurchaseOrder + { + DocNo = docNo, + VendorId = request.VendorId, + RequisitionId = request.RequisitionId, + // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04). + ApprovalRequired = false, + Status = PurchaseOrderStatus.Approved, + CreatedBy = actor, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(ToLine).ToList() + }; + await _pos.AddAsync(entity, token); + return entity; + }, ct); + + return new ETagged(Map(po), po.RowVersion); + } + + public async Task> UpdateAsync( + long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412); + + if (!IsEditable(po.Status)) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be edited.", 409); + + await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct); + + po.VendorId = request.VendorId; + po.RequisitionId = request.RequisitionId; + po.UpdatedAt = DateTime.UtcNow; + + // Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line). + po.Lines.Clear(); + foreach (var input in request.Lines) + po.Lines.Add(ToLine(input)); + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412); + } + + return new ETagged(Map(po), po.RowVersion); + } + + public async Task ApproveAsync(long poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + // Phase 1 no-op: POs are already Approved on creation. Kept for the future + // approval workflow (PendingApproval → Approved) — FR-PROC-04. + if (po.Status == PurchaseOrderStatus.PendingApproval) + { + po.Status = PurchaseOrderStatus.Approved; + po.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + return Map(po); + } + + public async Task CancelAsync(long poId, string? reason, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Lines.Any(l => l.QtyReceived > 0)) + throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it."); + + if (po.Status != PurchaseOrderStatus.Cancelled) + { + po.Status = PurchaseOrderStatus.Cancelled; + po.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + return Map(po); + } + + private static bool IsEditable(PurchaseOrderStatus status) => status is not ( + PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled); + + private static PoLine ToLine(CreatePoLineInput l) => new() + { + ItemId = l.ItemId, + UomId = l.UomId, + WarehouseId = l.WarehouseId, + Qty = l.Qty, + UnitPrice = l.UnitPrice, + Tax = l.Tax, + QtyReceived = 0 + }; + + private static PoTotalsDto ComputeTotals(IEnumerable lines) + { + decimal sub = 0, tax = 0; + foreach (var l in lines) + { + var net = l.Qty * l.UnitPrice; + sub += net; + tax += net * l.Tax; + } + sub = Math.Round(sub, 2, MidpointRounding.AwayFromZero); + tax = Math.Round(tax, 2, MidpointRounding.AwayFromZero); + return new PoTotalsDto(sub, tax, sub + tax, BaseCurrency); + } + + private async Task ValidateReferencesAsync( + long vendorId, long? requisitionId, IReadOnlyCollection lines, CancellationToken ct) + { + var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); + if (vendor is null) + throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422); + if (vendor.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422); + + if (requisitionId is not null + && !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422); + + await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct); + await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct); + await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct); + } + + private static async Task EnsureAllExistAsync( + IQueryable keySource, IEnumerable requested, string label, CancellationToken ct) + { + var ids = requested.Distinct().ToList(); + var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422); + } + + private static PurchaseOrderDto Map(PurchaseOrder p) => new( + p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired, + p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines), + p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto( + l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList()); +} diff --git a/Backend/ERPCore/Services/RequisitionService.cs b/Backend/ERPCore/Services/RequisitionService.cs new file mode 100644 index 0000000..08b88d6 --- /dev/null +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -0,0 +1,119 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class RequisitionService : IRequisitionService +{ + private readonly IRepository _requisitions; + private readonly IRepository _items; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public RequisitionService( + IRepository requisitions, IRepository items, + INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + { + _requisitions = requisitions; + _items = items; + _numbers = numbers; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + { + var q = _requisitions.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.RequisitionId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(long requisitionId, CancellationToken ct = default) + { + var req = await _requisitions.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct); + return req is null ? null : Map(req); + } + + public async Task CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default) + { + await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct); + var actor = _currentUser.AuditUserId; + + var req = await _uow.ExecuteInTransactionAsync(async token => + { + var docNo = await _numbers.NextAsync(DocumentTypes.Requisition, token); + var entity = new Requisition + { + DocNo = docNo, + RequestedBy = actor, + Status = RequisitionStatus.Draft, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new RequisitionLine + { + ItemId = l.ItemId, + Qty = l.Qty, + RequiredBy = l.RequiredBy + }).ToList() + }; + await _requisitions.AddAsync(entity, token); + return entity; + }, ct); + + return Map(req); + } + + public async Task SubmitAsync(long requisitionId, CancellationToken ct = default) + { + var req = await _requisitions.Query() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct) + ?? throw new NotFoundException($"Requisition {requisitionId} was not found."); + + if (req.Status != RequisitionStatus.Submitted) + { + req.Status = RequisitionStatus.Submitted; + await _uow.SaveChangesAsync(ct); + } + + return Map(req); + } + + private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) + { + var ids = itemIds.Distinct().ToList(); + var found = await _items.Query().AsNoTracking() + .Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422); + } + + private static RequisitionDto Map(Requisition r) => new( + r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, + r.Lines.OrderBy(l => l.ReqLineId) + .Select(l => new RequisitionLineDto(l.ReqLineId, l.ItemId, l.Qty, l.RequiredBy)) + .ToList()); +} diff --git a/Backend/ERPCore/Services/RfqService.cs b/Backend/ERPCore/Services/RfqService.cs new file mode 100644 index 0000000..defcab6 --- /dev/null +++ b/Backend/ERPCore/Services/RfqService.cs @@ -0,0 +1,157 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Procurement; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class RfqService : IRfqService +{ + private readonly IRepository _rfqs; + private readonly IRepository _requisitions; + private readonly IRepository _items; + private readonly IRepository _vendors; + private readonly IRepository _quotations; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public RfqService( + IRepository rfqs, IRepository requisitions, IRepository items, + IRepository vendors, IRepository quotations, + INumberSequenceService numbers, IUnitOfWork uow) + { + _rfqs = rfqs; + _requisitions = requisitions; + _items = items; + _vendors = vendors; + _quotations = quotations; + _numbers = numbers; + _uow = uow; + } + + public async Task GetAsync(long rfqId, CancellationToken ct = default) + { + var rfq = await _rfqs.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RfqId == rfqId, ct); + return rfq is null ? null : MapRfq(rfq); + } + + public async Task CreateAsync(CreateRfqRequest request, CancellationToken ct = default) + { + if (!await _requisitions.Query().AnyAsync(r => r.RequisitionId == request.RequisitionId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Requisition {request.RequisitionId} does not exist.", 422); + + await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct); + await EnsureVendorsExistAsync(request.VendorIds, ct); + + var rfq = await _uow.ExecuteInTransactionAsync(async token => + { + var docNo = await _numbers.NextAsync(DocumentTypes.Rfq, token); + var entity = new Rfq + { + DocNo = docNo, + RequisitionId = request.RequisitionId, + Status = RfqStatus.Open, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new RfqLine { ItemId = l.ItemId, Qty = l.Qty }).ToList() + }; + await _rfqs.AddAsync(entity, token); + return entity; + }, ct); + + return MapRfq(rfq); + } + + public async Task AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default) + { + var rfq = await _rfqs.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RfqId == rfqId, ct) + ?? throw new NotFoundException($"RFQ {rfqId} was not found."); + + if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422); + + var rfqItemIds = rfq.Lines.Select(l => l.ItemId).ToHashSet(); + var offLine = request.Lines.Select(l => l.ItemId).FirstOrDefault(id => !rfqItemIds.Contains(id)); + if (offLine != 0) + throw new DomainException(ErrorCodes.Validation, $"Item {offLine} is not part of RFQ {rfqId}.", 422); + + if (await _quotations.Query().AnyAsync(q => q.RfqId == rfqId && q.VendorId == request.VendorId, ct)) + throw new ConflictException($"Vendor {request.VendorId} has already quoted RFQ {rfqId}."); + + var quotation = new VendorQuotation + { + RfqId = rfqId, + VendorId = request.VendorId, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new VendorQuotationLine + { + ItemId = l.ItemId, + UnitPrice = l.UnitPrice, + LeadDays = l.LeadDays + }).ToList() + }; + await _quotations.AddAsync(quotation, ct); + await _uow.SaveChangesAsync(ct); + + return new VendorQuotationDto( + quotation.QuotationId, quotation.RfqId, quotation.VendorId, + quotation.Lines.Select(l => new QuotationLineDto(l.ItemId, l.UnitPrice, l.LeadDays)).ToList()); + } + + public async Task GetComparisonAsync(long rfqId, CancellationToken ct = default) + { + var rfq = await _rfqs.Query().AsNoTracking() + .Include(r => r.Lines) + .Include(r => r.Quotations).ThenInclude(q => q.Lines) + .FirstOrDefaultAsync(r => r.RfqId == rfqId, ct) + ?? throw new NotFoundException($"RFQ {rfqId} was not found."); + + var vendorIds = rfq.Quotations.Select(q => q.VendorId).Distinct().OrderBy(v => v).ToList(); + + var rows = rfq.Lines.OrderBy(l => l.RfqLineId).Select(line => + { + var cells = rfq.Quotations + .Select(q => new { q.VendorId, q.QuotationId, Line = q.Lines.FirstOrDefault(ql => ql.ItemId == line.ItemId) }) + .Where(x => x.Line is not null) + .OrderBy(x => x.VendorId) + .Select(x => new RfqComparisonCellDto(x.VendorId, x.QuotationId, x.Line!.UnitPrice, x.Line!.LeadDays)) + .ToList(); + return new RfqComparisonRowDto(line.ItemId, line.Qty, cells); + }).ToList(); + + return new RfqComparisonDto(rfqId, vendorIds, rows); + } + + private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) + { + var ids = itemIds.Distinct().ToList(); + var found = await _items.Query().AsNoTracking() + .Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422); + } + + private async Task EnsureVendorsExistAsync(IEnumerable vendorIds, CancellationToken ct) + { + var ids = vendorIds.Distinct().ToList(); + if (ids.Count == 0) return; + var found = await _vendors.Query().AsNoTracking() + .Where(v => ids.Contains(v.VendorId)).Select(v => v.VendorId).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Vendor(s) not found: {string.Join(", ", missing)}.", 422); + } + + private static RfqDto MapRfq(Rfq r) => new( + r.RfqId, r.DocNo, r.RequisitionId, r.Status, + r.Lines.OrderBy(l => l.RfqLineId).Select(l => new RfqLineDto(l.RfqLineId, l.ItemId, l.Qty)).ToList()); +} diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 4025ec0..66d7ab5 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -25,10 +25,14 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation) ## 2. Procurement -- [ ] Requisition (+ lines) + submit -- [ ] RFQ + quotations + comparison -- [ ] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open, approve (no-op), cancel -- [ ] Purchase Return (outbound movement, reason code) +> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired. +- [~] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get) +- [~] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix) +- [~] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel +- [ ] Purchase Return (outbound movement, reason code) — **deferred**: needs GRN lines + stock ledger/FIFO (§3/§4). Build with those. + +> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match. +> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. ## 3. Goods Receipt - [ ] GRN create (against PO / direct), over-receipt tolerance @@ -50,8 +54,8 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 6. Cross-cutting > **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so §1 endpoints are currently open. This is the AR-01/NFR-03 control surface — gate all v1 endpoints (fallback authorization policy) in the same change as `POST /auth/login`, then re-run the 02-SECURITY B.1 checklist and flip §1 items to `[x]`. - [ ] Audit log on every mutation (who/when/old→new) -- [ ] Document numbering sequences (per type, per year) -- [ ] Auth: simple in-app login → JWT (`POST /auth/login`) +- [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO. +- [~] Auth: simple in-app login → JWT (`POST /auth/login`) — foundation only: `User` table + seeded `system` user (id 1) exist and `ICurrentUser.AuditUserId` stamps docs; login endpoint + `[Authorize]` still pending. - [ ] JournalEntryStub emitted per stock movement (data only) - [ ] Negative-stock policy enforcement (default block) - [ ] FEFO picking for perishables; block expired / on-hold issue @@ -76,3 +80,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - `dotnet ef database update` applied `InitialCreate` to local Postgres; `/health` → `Healthy`. - End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child + `?tree=true` nesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filter `q` + `pageSize=9999`→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400 `SKU_DUPLICATE`; bad reference→422; missing required→400 ValidationProblemDetails; bad enum→400. Concurrency token (`xmin`) confirmed incrementing per mutation. - Note: local dev DB now holds smoke-test rows (warehouse/bin/uom×2/category×2/vendor/item, item left Inactive). Reset any time with `dotnet ef database drop -f && dotnet ef database update`. + +### 2026-07-10 — Procurement (§2, minus returns) + cross-cutting foundations +- Cross-cutting: `User` entity (+ seeded `system` user via `HasData`), `ICurrentUser.AuditUserId` (numeric actor, system fallback), `NumberSequence` + `NumberSequenceService` (atomic per-type/per-year doc numbers issued inside the UoW txn). +- Procurement: enums (RequisitionStatus, RfqStatus, PurchaseOrderStatus); 8 entities (Requisition/Line, Rfq/Line, VendorQuotation/Line, PurchaseOrder/Line) + configs; DTOs; 3 services; 3 controllers (`/requisitions`, `/rfqs`, `/purchase-orders`). PO carries the `xmin` ETag token; totals computed server-side; create/edit wrapped in `IUnitOfWork.ExecuteInTransactionAsync` so the reserved doc number rolls back with the doc. +- Migration `AddProcurement` generated + applied (10 tables incl. users/number_sequences; system-user seed; PO `xmin` emits no DDL). +- **Verified:** build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422. +- **Deferred/next:** Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to `[x]`. +- Housekeeping: an empty user-created migration `20260709124415_initial` sits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning).