From 783696fa97a900d9373246af8a33fd7e4321f2ef Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Fri, 10 Jul 2026 11:55:32 +0530 Subject: [PATCH 1/9] smoke test complete on featues --- Backend/PROGRESS.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 627abc8..4025ec0 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -7,7 +7,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 0. Bootstrap - [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4) - [x] Folder structure per 00-CORE §5.3 -- [~] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created** (`Infra/Persistence/Migrations`, 8 master-data tables) — **not yet applied**: `dotnet ef database update` fails `28P01 password authentication failed for user "postgres"` (local Postgres is running on :5432 but the `postgres/postgres` dev creds in `appsettings.Development.json` don't match this box). Generated SQL script validates cleanly. **Action needed:** set the real local creds, then `ASPNETCORE_ENVIRONMENT=Development dotnet ef database update`. +- [x] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created and applied** (2026-07-10, 8 master-data tables). `/health` → `Healthy`. - [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` (JWT bearer *validated*; endpoints not yet `[Authorize]`-gated — see §6 auth note) - [x] `IUnitOfWork` + `UnitOfWork` (transaction boundary) - [x] Generic repository base + interfaces @@ -15,7 +15,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`) ## 1. Master Data -> Code complete for all items below (2026-07-09): entities, EF configs, DTOs, services, controllers — solution builds clean, app boots, and the generated OpenAPI exposes every path in `docs/11 §2`. Marked `[~]` (not `[x]`) because the **security gate** (00-CORE §8) is not yet fully met: the foundational auth control (02-SECURITY B.1) and the audit trail (B.3, the AR-01 compensating control) land in §6, and the schema is not yet applied to a DB. No live DB integration test has run (creds blocker above). Flip to `[x]` once §6 auth+audit are in and endpoints are exercised against Postgres. +> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. Still `[~]` (not `[x]`) for **one** reason: the **security gate** (00-CORE §8) — the foundational auth control (02-SECURITY B.1) and the audit trail (B.3, the AR-01 compensating control) land in §6. Flip to `[x]` once §6 auth+audit are wired. - [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU - [~] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1) - [~] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert) @@ -70,4 +70,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - API: 5 controllers, lowercase routes matching `docs/11 §2` exactly (verified via generated `swagger.json`). ETag/If-Match (428 if missing, 412 on mismatch), narrow request DTOs (no over-posting), `PagedResponse` list envelope (§1.4), `PageQuery` with pageSize clamp ≤200 (B.6). - Migration `InitialCreate` generated (`xmin` correctly produces no DDL — uses the PG system column). - **Verified:** `dotnet build` clean (0 warn/0 err); app boots (`Now listening… Application started`); `/api/meta` 200; `swagger.json` 200 with all 13 master-data paths; DI resolves controller→service→repo→DbContext (a DB-backed call reaches Npgsql, failing only on creds). -- **Blocked / follow-ups:** (1) apply migration — needs real local Postgres creds (see §0 note); (2) auth enforcement + audit trail — §6 (security gate for `[x]`); (3) no `DELETE` master endpoints — `MASTER_IN_USE` code reserved until transaction tables exist (deactivate-only per FR-MD-08). +- **Blocked / follow-ups:** (1) auth enforcement + audit trail — §6 (the remaining security gate for `[x]`); (2) no `DELETE` master endpoints — `MASTER_IN_USE` code reserved until transaction tables exist (deactivate-only per FR-MD-08); (3) minor: bad-enum bind error leaks the CLR type name in `detail` (02-SECURITY B.5) — fine in Dev, tidy before prod. + +### 2026-07-10 — Migration applied + live smoke test PASSED +- `dotnet ef database update` applied `InitialCreate` to local Postgres; `/health` → `Healthy`. +- End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child + `?tree=true` nesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filter `q` + `pageSize=9999`→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400 `SKU_DUPLICATE`; bad reference→422; missing required→400 ValidationProblemDetails; bad enum→400. Concurrency token (`xmin`) confirmed incrementing per mutation. +- Note: local dev DB now holds smoke-test rows (warehouse/bin/uom×2/category×2/vendor/item, item left Inactive). Reset any time with `dotnet ef database drop -f && dotnet ef database update`. -- 2.52.0 From 4e84a15db7911ff4c767b832acc269a92629d011 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Mon, 13 Jul 2026 10:28:58 +0530 Subject: [PATCH 2/9] 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). -- 2.52.0 From 7ac30bb454ae69b8cbcbf331e24b061d8eed5717 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Mon, 13 Jul 2026 17:11:40 +0530 Subject: [PATCH 3/9] feat: add warehouse and bin management API with mock data implementation - Implemented warehouses API with methods for listing, creating, and managing bins. - Added wastage API to handle stock write-offs and integrate with stock adjustments. - Created auth token management for storing and retrieving access tokens. - Developed error mapping for consistent user-facing error messages. - Introduced client-side validations for GRN, master data, and procurement processes. - Defined common types for pagination, problem details, and various master data entities. - Established procurement and stock management types to support frontend functionality. --- Frontend/PROGRESS.md | 132 ++- Frontend/erp-system/app/dashboard/layout.tsx | 2 + .../app/dashboard/procurement/page.tsx | 64 ++ .../procurement/purchase-orders/[id]/page.tsx | 459 ++++++++++ .../procurement/purchase-orders/new/page.tsx | 411 +++++++++ .../procurement/purchase-orders/page.tsx | 186 ++++ .../procurement/purchase-returns/new/page.tsx | 286 ++++++ .../procurement/purchase-returns/page.tsx | 117 +++ .../procurement/requisitions/[id]/page.tsx | 144 ++++ .../procurement/requisitions/new/page.tsx | 213 +++++ .../procurement/requisitions/page.tsx | 151 ++++ .../dashboard/procurement/rfqs/[id]/page.tsx | 332 +++++++ .../dashboard/procurement/rfqs/new/page.tsx | 280 ++++++ .../app/dashboard/procurement/rfqs/page.tsx | 104 +++ .../app/dashboard/products/[id]/page.tsx | 592 +++++++++++++ .../dashboard/products/categories/page.tsx | 166 ++++ .../app/dashboard/products/new/page.tsx | 220 +++++ .../app/dashboard/products/page.tsx | 249 +++++- .../app/dashboard/products/uoms/page.tsx | 134 +++ .../receiving/grn/[id]/edit/page.tsx | 444 ++++++++++ .../app/dashboard/receiving/grn/[id]/page.tsx | 233 +++++ .../app/dashboard/receiving/grn/new/page.tsx | 555 ++++++++++++ .../app/dashboard/receiving/grn/page.tsx | 313 +++++++ .../dashboard/stock/adjustments/new/page.tsx | 270 ++++++ .../app/dashboard/stock/adjustments/page.tsx | 107 +++ .../app/dashboard/stock/counts/[id]/page.tsx | 170 ++++ .../app/dashboard/stock/counts/new/page.tsx | 162 ++++ .../app/dashboard/stock/counts/page.tsx | 108 +++ .../app/dashboard/stock/enquiry/page.tsx | 164 ++++ .../app/dashboard/stock/ledger/page.tsx | 240 ++++++ .../erp-system/app/dashboard/stock/page.tsx | 98 +++ .../dashboard/stock/reorder-alerts/page.tsx | 140 +++ .../dashboard/stock/transfers/[id]/page.tsx | 170 ++++ .../dashboard/stock/transfers/new/page.tsx | 298 +++++++ .../app/dashboard/stock/transfers/page.tsx | 108 +++ .../app/dashboard/stock/valuation/page.tsx | 180 ++++ .../app/dashboard/stock/wastage/new/page.tsx | 246 ++++++ .../app/dashboard/stock/wastage/page.tsx | 197 +++++ .../app/dashboard/vendors/[id]/page.tsx | 219 +++++ .../erp-system/app/dashboard/vendors/page.tsx | 385 +++++++++ .../app/dashboard/warehouse/[id]/page.tsx | 168 ++++ .../app/dashboard/warehouse/page.tsx | 178 ++++ .../components/Layouts/AppSidebar.tsx | 10 + .../components/Layouts/Breadcrumbs.tsx | 84 ++ .../erp-system/components/Layouts/Header.tsx | 53 ++ .../components/procurement/status-badges.tsx | 45 + .../components/receiving/status-badges.tsx | 34 + .../components/stock/status-badges.tsx | 35 + Frontend/erp-system/components/ui/select.tsx | 201 +++++ Frontend/erp-system/lib/api-client.ts | 98 +++ Frontend/erp-system/lib/api/categories.ts | 64 ++ Frontend/erp-system/lib/api/grns.ts | 282 ++++++ Frontend/erp-system/lib/api/items.ts | 203 +++++ Frontend/erp-system/lib/api/mock-data.ts | 814 ++++++++++++++++++ .../erp-system/lib/api/purchase-orders.ts | 201 +++++ .../erp-system/lib/api/purchase-returns.ts | 113 +++ Frontend/erp-system/lib/api/reason-codes.ts | 27 + Frontend/erp-system/lib/api/requisitions.ts | 103 +++ Frontend/erp-system/lib/api/rfqs.ts | 126 +++ .../erp-system/lib/api/stock-adjustments.ts | 152 ++++ Frontend/erp-system/lib/api/stock-counts.ts | 198 +++++ .../erp-system/lib/api/stock-transfers.ts | 221 +++++ Frontend/erp-system/lib/api/stock.ts | 190 ++++ Frontend/erp-system/lib/api/uoms.ts | 43 + Frontend/erp-system/lib/api/vendors.ts | 165 ++++ Frontend/erp-system/lib/api/warehouses.ts | 98 +++ Frontend/erp-system/lib/api/wastage.ts | 98 +++ Frontend/erp-system/lib/auth-token.ts | 16 + Frontend/erp-system/lib/error-map.ts | 41 + Frontend/erp-system/lib/validations/grn.ts | 57 ++ .../erp-system/lib/validations/master-data.ts | 52 ++ .../erp-system/lib/validations/procurement.ts | 63 ++ Frontend/erp-system/next.config.ts | 10 - Frontend/erp-system/package.json | 2 +- Frontend/erp-system/types/common.ts | 28 + Frontend/erp-system/types/grn.ts | 104 +++ Frontend/erp-system/types/master-data.ts | 136 +++ Frontend/erp-system/types/procurement.ts | 243 ++++++ Frontend/erp-system/types/stock.ts | 281 ++++++ 79 files changed, 14042 insertions(+), 44 deletions(-) create mode 100644 Frontend/erp-system/app/dashboard/procurement/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/categories/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/uoms/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/counts/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/ledger/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/transfers/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/valuation/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/wastage/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/vendors/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/warehouse/page.tsx create mode 100644 Frontend/erp-system/components/Layouts/Breadcrumbs.tsx create mode 100644 Frontend/erp-system/components/procurement/status-badges.tsx create mode 100644 Frontend/erp-system/components/receiving/status-badges.tsx create mode 100644 Frontend/erp-system/components/stock/status-badges.tsx create mode 100644 Frontend/erp-system/components/ui/select.tsx create mode 100644 Frontend/erp-system/lib/api-client.ts create mode 100644 Frontend/erp-system/lib/api/categories.ts create mode 100644 Frontend/erp-system/lib/api/grns.ts create mode 100644 Frontend/erp-system/lib/api/items.ts create mode 100644 Frontend/erp-system/lib/api/mock-data.ts create mode 100644 Frontend/erp-system/lib/api/purchase-orders.ts create mode 100644 Frontend/erp-system/lib/api/purchase-returns.ts create mode 100644 Frontend/erp-system/lib/api/reason-codes.ts create mode 100644 Frontend/erp-system/lib/api/requisitions.ts create mode 100644 Frontend/erp-system/lib/api/rfqs.ts create mode 100644 Frontend/erp-system/lib/api/stock-adjustments.ts create mode 100644 Frontend/erp-system/lib/api/stock-counts.ts create mode 100644 Frontend/erp-system/lib/api/stock-transfers.ts create mode 100644 Frontend/erp-system/lib/api/stock.ts create mode 100644 Frontend/erp-system/lib/api/uoms.ts create mode 100644 Frontend/erp-system/lib/api/vendors.ts create mode 100644 Frontend/erp-system/lib/api/warehouses.ts create mode 100644 Frontend/erp-system/lib/api/wastage.ts create mode 100644 Frontend/erp-system/lib/auth-token.ts create mode 100644 Frontend/erp-system/lib/error-map.ts create mode 100644 Frontend/erp-system/lib/validations/grn.ts create mode 100644 Frontend/erp-system/lib/validations/master-data.ts create mode 100644 Frontend/erp-system/lib/validations/procurement.ts delete mode 100644 Frontend/erp-system/next.config.ts create mode 100644 Frontend/erp-system/types/common.ts create mode 100644 Frontend/erp-system/types/grn.ts create mode 100644 Frontend/erp-system/types/master-data.ts create mode 100644 Frontend/erp-system/types/procurement.ts create mode 100644 Frontend/erp-system/types/stock.ts diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index cbf7fca..8e8075d 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -5,11 +5,13 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. ## 0. Foundation -- [ ] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local`) -- [ ] Typed API client / fetch wrapper (one method per endpoint) + bearer token handling -- [ ] Shared TS types mirroring API DTOs (`types/`) -- [ ] Dependency-free client validation helpers (format/required/range) -- [ ] `ProblemDetails` normalizer + `code → message` map (`lib/`) +- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — points at `Backend/ERPCore`'s https profile (`7112`) +- [x] Typed API client / fetch wrapper (`lib/api-client.ts`: `apiRequest`/`apiRequestWithETag`/`buildQuery`) + bearer token handling (`lib/auth-token.ts`, degrades gracefully — no login endpoint wired yet, see §1) +- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn) +- [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3) +- [x] `ProblemDetails` normalizer (`ApiError` in `lib/api-client.ts`) + `code → message` map (`lib/error-map.ts`) + +> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built. ## 1. Auth - [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage @@ -18,41 +20,111 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API ## 2. Master Data screens -- [ ] Items (list + create/edit, ETag handling) -- [ ] UOM + conversions -- [ ] Categories (tree) -- [ ] Vendors -- [ ] Warehouses + Bins -- [ ] Item reorder settings +- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. +- [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03 +- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04 +- [~] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult` was built earlier but unused until now). +- [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create. +- [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05 ## 3. Procurement screens -- [ ] Requisition (create + submit) -- [ ] RFQ + quotations + comparison view -- [ ] Purchase Order (create, edit-while-open, cancel) -- [ ] Purchase Return +- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01 +- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02 +- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07 +- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page ## 4. Receiving screens -- [ ] GRN create (qty, bin, batch/serial capture) -- [ ] GRN confirm (render created layers / ledger refs) -- [ ] Inspection hold release / reject +- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail +- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode` +- [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session +- [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` +- Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` + +> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below). +> +> **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`). +> +> **Deviation — `GET /grns` and `GET /grns/{id}`:** the API doc only specifies `POST /grns`, `POST /grns/{id}/confirm`, `POST /grns/{id}/lines/{id}/release` (no list/detail read). A list screen and a confirm/release screen both need to reload a GRN, so `lib/api/grns.ts` (`grnsApi.list`/`grnsApi.get`) and `types/grn.ts` assume these two GET endpoints will exist once the backend is built — flag this to whoever implements `Backend/PROGRESS.md` §3 so `docs/11-BACKEND-PHASE1.md` gets the corresponding doc update. ## 5. Stock screens -- [ ] Stock enquiry (onHand/available/onHold/inTransit) -- [ ] Ledger view · Valuation view -- [ ] Transfer (create → dispatch → receive) -- [ ] Adjustment (reason code required) -- [ ] Count (cycle/full → enter → post) -- [ ] Reorder alerts (+ create requisition) +- [~] Stock hub (`app/dashboard/stock/page.tsx`) — card grid linking to all 7 areas below +- [~] Stock enquiry (`.../stock/enquiry`) — onHand/available/onHold/inTransit/reserved, search by SKU/name + warehouse filter, links to Valuation per row +- [~] Ledger view (`.../stock/ledger`) — filterable by item/warehouse/date range, paginated +- [~] Valuation view (`.../stock/valuation`) — item+warehouse picker (also reachable via `?itemId=&warehouseId=` from Enquiry), FIFO layer breakdown + totals +- [~] Transfer (`.../stock/transfers` list, `/new` create, `/[id]` dispatch → receive) — cost-preserving per line (FR-STK-06) +- [~] Adjustment (`.../stock/adjustments` list, `/new` create) — reason code mandatory, auto-posts on submit (no separate confirm step, matching FR-STK-07) +- [~] Count (`.../stock/counts` list, `/new` create, `/[id]` enter counts → post) — posting creates a linked variance adjustment +- [~] Reorder alerts (`.../stock/reorder-alerts`) — items ≤ reorder point, one-click "Create requisition" +- [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type). +- Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`) + +> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). +> +> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions). +> +> **Simplifications (mock-data limitations, not spec decisions):** `StockLayer` has no per-bin field (matches the real ER model, docs/10 Part C.5 — only `StockLedger` carries `bin_id`), so Count lines don't attempt bin-level snapshotting. Transfers don't expose batch selection in the create UI (FIFO picks layers regardless of batch). Adjustment increases always cost at "last known cost" for that item/warehouse (FR-STK-07); there's no landed-cost/manual-cost override. In-transit quantity is shown for visibility at the destination warehouse only and is not subtracted a second time from the source's `available` (dispatch already reduced the source layer's `qtyRemaining`) — the docs' `available = onHand − onHold − reserved − inTransit(out)` formula is ambiguous on this point given dispatch semantics; this was a judgment call, noted here for whoever builds the real backend to confirm or correct. ## 6. Validation posture (20-FRONTEND §3) -- [ ] Client format/required/range checks on all forms -- [ ] Surface server `ProblemDetails` incl. domain codes; map to fields/messages -- [ ] `412` conflict → prompt refetch before retry -- [ ] No client-side gating on stock/availability/status (server-authoritative) +- [~] Client format/required/range checks on all forms — done for GRN create (`lib/validations/grn.ts`); not yet done for other forms +- [x] Surface server `ProblemDetails` incl. domain codes; map to fields/messages — `lib/error-map.ts` (`errorMessage`/`fieldErrors`), used by GRN create/detail +- [ ] `412` conflict → prompt refetch before retry — `apiRequestWithETag` surfaces the ETag but no screen edits a GRN yet (GRN has no PUT), so untested in practice +- [x] No client-side gating on stock/availability/status (server-authoritative) — GRN create always submits to the server and surfaces `OVER_RECEIPT_TOLERANCE`/etc. via `error-map.ts` rather than pre-blocking ## 7. UX states -- [ ] Loading / empty / error states on every list -- [ ] Transactional actions show server-returned side effects as confirmation +- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt +- [x] Transactional actions show server-returned side effects as confirmation — GRN confirm renders `createdLayers`/`ledgerRefs`/`poStatus` from the response ## Done + +### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes) +- Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface. +- Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers. +- Screens: GRN list, GRN create (PO-based + direct receipt, batch/serial capture by `trackingMode`), GRN detail (confirm + release/reject). Sidebar nav entry added. +- **This was explicitly frontend-only** (user interrupted an initial backend+frontend plan and asked for frontend only). No GRN backend exists — `Backend/PROGRESS.md` §3/§4 are unchanged. The screens are built against the contract in `docs/11-BACKEND-PHASE1.md` §4 plus two assumed-but-undocumented endpoints (`GET /grns`, `GET /grns/{id}`, see §4 note above); none of it is runnable end-to-end yet. +- Verified: `tsc --noEmit` clean for all new/edited files (one pre-existing, unrelated error remains in `app/login/page.tsx`); `eslint` clean aside from two `react-hooks/set-state-in-effect` warnings matching an already-existing pattern in `hooks/use-mobile.ts`; all three routes confirmed rendering (200, correct content, no error boundary) via SSR against the dev server. + +### 2026-07-13 — Stock Management screens (frontend-only; no backend changes) +- `types/stock.ts`: full DTO set for on-hand, ledger, valuation, transfers, adjustments, counts, reorder alerts (docs/11 §5). +- `lib/api/mock-data.ts` gained a real in-memory Stock Core: `mockStockLayers`/`mockStockLedger` + `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`/`lastKnownCost` helpers, plus `mockItemReorders` and `mockReasonCodes` seed data. `lib/api/grns.ts`'s `confirm()` was refactored to post through these helpers instead of fabricating a response, and now also accrues PO `qtyReceived`/recomputes PO status — so GRN and Stock screens are genuinely connected this session. +- New API modules: `lib/api/stock.ts` (on-hand/ledger/valuation/reorder-alerts), `stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`, `reason-codes.ts` — same commented-real-block + active-mock-block pattern as the GRN modules. +- Screens: hub, Enquiry, Ledger, Valuation, Transfers (list/new/detail with dispatch+receive), Adjustments (list/new, auto-post), Counts (list/new/detail with enter-counts+post), Reorder Alerts. New shared badge set `components/stock/status-badges.tsx` (same fixed-size red/green/yellow convention as `components/receiving/status-badges.tsx`). Sidebar + header-title mappings added. +- Same posture as the GRN pass: `[~]` not `[x]`, frontend built ahead of a nonexistent Stock Core backend, deviations/simplifications recorded in the §5 note above. `tsc --noEmit` and `eslint` clean (only the same pre-existing/established issues as the GRN pass). + +### 2026-07-13 — Wastage screens (frontend-only; no backend changes) +- `lib/api/wastage.ts`: no new backend concept — confirmed with the user that "Wastage" should be a focused UI lens over the just-built Stock Adjustments (damage/theft-loss/expiry write-off reason codes), not a distinct document type. Filters `mockStockAdjustments` to loss-type reason codes, flattens to per-item `WastageRecord`s, and computes cost per record from matching outbound `mockStockLedger` entries. +- Screens: `.../stock/wastage` (report — totals cards, warehouse/reason filters, per-item table) and `.../stock/wastage/new` (single-line record form, reason dropdown restricted to wastage-type codes, posts via the existing `stockAdjustmentsApi.create`). Added a "Wastage" card to the Stock hub and header-title mappings. +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from one more instance of the already-established `set-state-in-effect` pattern. + +### 2026-07-13 — Warehouse Management screens (frontend-only; no backend changes) +- Scope, per user selection out of the four FR-WH sub-areas offered (Warehouses & Bins / Stock Locator / Batch & Serial / Putaway): **Warehouses & Bins master data only** (FR-WH-01, FR-MD-07). The other three (bin-level Stock Locator, Batch/Serial tracking, Putaway) were **not** built — flagged here so a future pass knows they're still open, not forgotten. +- `lib/api/warehouses.ts` gained `create`/`get`/`createBin` (previously list/listBins only, read-only) — duplicate-code validation mirrors the real `SKU_DUPLICATE`-style 400 pattern used elsewhere. `mock-data.ts` gained `allocateWarehouseId`/`allocateBinId`. +- Screens: `app/dashboard/warehouse` (list + "New Warehouse" `Dialog` form) and `app/dashboard/warehouse/[id]` (bin list + "New Bin" `Dialog` form) — used `components/ui/dialog.tsx` instead of a full page for these two-field creates, since a whole page felt heavy for that. Sidebar "Warehouses" entry + header-title mapping added. +- Housekeeping: removed two stray duplicate route folders (`app/dashboard/receiving/grn/create new GRN/`, `.../view GRN/`) that were byte-for-byte copies of the real `new/` and `[id]/` GRN pages under garbled folder names — almost certainly an IDE artifact from an earlier malformed file-open path, not intentional work (confirmed untracked in git before removing). Also noted, but deliberately left alone: `app/warehouse/*`, `components/warehouse/`, `lib/warehouse/` are pre-existing **empty** scaffold folders (no files at all) from initial project setup — Warehouse Management was built under `app/dashboard/warehouse/*` instead so it gets the dashboard chrome (sidebar/header) for free, consistent with every other screen this session. +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from one `exhaustive-deps` warning (not an error) on `[id]/page.tsx`'s `loadBins` helper. + +### 2026-07-13 — Vendor (Supplier) management screens (frontend-only; no backend changes) +- Confirmed with the user first: "supplier/shop" has no distinct "Shop" entity in the SRS/docs — scoped this to the documented Vendor master (FR-MD-06, docs/11 §2.4), "supplier" being the standard ERP synonym. +- `lib/api/vendors.ts` extended from list-only to `get`/`create`/`update`/`updateStatus`. This is the **first screen to exercise the ETag/If-Match/412 pattern**: `mock-data.ts` gained a per-vendor concurrency-token map (`getVendorVersion`/`bumpVendorVersion`/`initVendorVersion`, standing in for the real backend's `xmin` — the public `Vendor` type has no version field of its own since it travels as an HTTP `ETag` header, not a body field) so `update()` genuinely rejects a stale `If-Match` with `CONCURRENCY_CONFLICT`, matching `docs/11 §1.6` and `20-FRONTEND.md §3.2`. +- Screens: `app/dashboard/vendors` (list, search + status filter, "New Vendor" dialog) and `app/dashboard/vendors/[id]` (full edit form using the real `apiRequestWithETag`-shaped `ApiResult`, a dedicated conflict banner with "Reload before retrying" per the 412 UX rule rather than a generic toast, and an Activate/Deactivate toggle via `PATCH status`, FR-MD-08 — deactivate, not hard-delete). Sidebar "Vendors" entry + header-title mapping added. +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session. +- **Follow-up (same day):** `vendorsApi.list()` didn't actually paginate (always returned page 1 / all matches, same latent gap the GRN list had before its own pagination pass) — fixed to slice by `page`/`pageSize` properly, added the same Previous/Next pagination controls used on the GRN and Stock list screens, and seeded 8 more sample vendors so there's something real to page through. + +### 2026-07-13 — Procurement screens: Requisition → RFQ → PO → Purchase Return (frontend-only; no backend changes) +- `types/procurement.ts` grew from a GRN-support subset (PO read types only) to the full §3 DTO set: Requisition/ReqLine, Rfq/RfqLine/Quotation/RfqComparison, PO create/update/cancel request types, PurchaseReturn/PurchaseReturnLine — mirrors `docs/11-BACKEND-PHASE1.md` §3 request/response JSON exactly (no `deliveryDate` field on PO lines, since the documented `POST /purchase-orders` example doesn't carry one despite FR-PROC-03's prose — contract-over-prose per `docs/20-FRONTEND.md` §1). +- `lib/api/mock-data.ts`: added `mockRequisitions`/`mockRfqs`/`mockQuotations`/`mockPurchaseReturns` + allocators, a PO concurrency-token map (`getPoVersion`/`bumpPoVersion`/`initPoVersion`, same out-of-band ETag pattern as vendors), and `consumeLayerByGrnLine` — a *new* consumption path deliberately separate from `consumeFifo`: a Purchase Return disposes of the exact layer its GRN line created (often `OnHold`/`Rejected`, which `consumeFifo`'s hold filter would otherwise skip), not "the oldest open layer for this item/warehouse". Seeded Requisition #210 to match the existing `mockPurchaseOrders[0].requisitionId` so the two screens cross-reference. +- New API modules: `lib/api/requisitions.ts`, `lib/api/rfqs.ts` (create/addQuotation/comparison — comparison is computed client-side from recorded quotations), `lib/api/purchase-returns.ts`. `lib/api/purchase-orders.ts` extended from list/get-only (its original GRN-support scope) to full create/update/cancel; added `getWithETag`/`isPoEditable` without touching the existing plain `get()` GRN's create-flow already depends on, so no existing call site broke. +- **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core. +- Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation). +- Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes. +- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged). +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server. + +### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes) +- `types/master-data.ts`: `ItemListItem` (the GRN/PO/Requisition/RFQ item-picker subset built in earlier sessions) is now a derived view of a new full `Item` type — SKU/name/description/category/baseUom/defaultVendor/type/trackingMode/taxClass/status plus `reorder: ItemReorderSetting[]` (matches the documented `GET /items/{itemId}` example inline) and `conversions: UomConversion[]` (**deviation**: the doc's example response only shows `reorder`, and conversions are otherwise reachable only via `PUT /items/{itemId}/uom-conversions` with no matching GET — embedding them on the full resource, like the assumed `GET /grns`/`GET /grns/{id}` reads elsewhere in this app, lets the Item detail screen show current conversions before editing). Also added `Category`/`CategoryTreeNode`, `CreateUomRequest`, `CreateCategoryRequest`, and Item create/update/reorder/conversion request types (docs/11 §2.1-2.3). +- `lib/api/mock-data.ts`: `mockItems` changed storage shape from `ItemListItem[]` to full `Item[]` (only `mock-data.ts` and `lib/api/items.ts` touched it directly, confirmed by grep, so no other call site broke) — `lib/api/items.ts`'s `list()` now maps down to `ItemListItem`, same "full record → mapped summary" pattern as `mockPurchaseOrders` → `PurchaseOrderSummary`. Added a per-item concurrency-token map (`getItemVersion`/`bumpItemVersion`/`initItemVersion`, same out-of-band ETag pattern as vendors/POs), `mockCategories` seeded with a 2-root/1-child tree matching the category IDs the existing sample items already reference (12 "Fasteners" under 3 "Hardware"; 20 "Power Tools"), and a UOM id allocator. +- New API modules: `lib/api/categories.ts` (`list`/`tree`/`create` — `tree()` builds the nested structure client-side from the flat list, since the mock has no separate tree-storage concept). `lib/api/items.ts` grew from list-only (its original GRN-picker scope) to full `get`/`create`/`update`/`updateStatus`/`updateReorder`/`updateUomConversions`; `lib/api/uoms.ts` gained `create`. +- Screens: Items (`app/dashboard/products` — **reused the pre-existing "Products" sidebar entry and stub route** rather than adding a new nav item, since it was already wired to an empty placeholder page; list has search + category/tracking-mode/status filters + pagination, `/new` create, `/[id]` detail combining three independently-saved sections in one page — basic info with ETag/If-Match + 412-conflict banner mirroring the Vendor `[id]` page, a Reorder Settings row-editor posting `PUT /items/{itemId}/reorder`, and a UOM Conversions row-editor posting `PUT /items/{itemId}/uom-conversions` — matching how the API groups these as sub-resources of Item rather than separate top-level screens). UOM (`app/dashboard/products/uoms` — flat list + create dialog, same shape as the Warehouses list). Categories (`app/dashboard/products/categories` — indented recursive tree view + create dialog with a parent picker). `lib/validations/master-data.ts` added (hand-rolled, matching the GRN validation file's style, not its `zod` deviation). Header title mappings added for all `/dashboard/products/*` routes. +- **Housekeeping:** removed `app/dashboard/vendors/view vendors/` — confirmed byte-for-byte identical to `vendors/[id]/page.tsx` and untracked in git, same IDE-artifact pattern (malformed file-open path) as the garbled GRN duplicate folders removed in the Warehouse Management pass; noted here rather than silently dropped. Left `app/dashboard/receiving/grn/[id]/edit/` alone — it's untracked too but is a distinct, non-duplicate GRN-edit screen, not an artifact. +- Same `[~]` posture as every other module this session: built against the documented+assumed Master Data contract (`docs/11-BACKEND-PHASE1.md` §2), no Master Data backend exists (`Backend/PROGRESS.md` §1 unchanged). +- Verified: `tsc --noEmit` clean after clearing a stale `.next` type cache that still referenced the just-deleted `view vendors` route (same pre-existing `login/page.tsx` error only remains); `eslint` clean aside from the same established `set-state-in-effect` pattern; `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure, unrelated). All 5 new/changed routes confirmed rendering 200 with no error boundary against the dev server (one false-alarm 500 during testing traced to an unrelated stale process already bound to port 3000, not this code — retested clean on the actual dev server port). diff --git a/Frontend/erp-system/app/dashboard/layout.tsx b/Frontend/erp-system/app/dashboard/layout.tsx index 3072700..49ec7f9 100644 --- a/Frontend/erp-system/app/dashboard/layout.tsx +++ b/Frontend/erp-system/app/dashboard/layout.tsx @@ -1,5 +1,6 @@ import { AppSidebar } from "@/components/Layouts/AppSidebar" import { Header } from "@/components/Layouts/Header" +import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs" import { Toaster } from "@/components/ui/toast" export default function DashboardLayout({ @@ -14,6 +15,7 @@ export default function DashboardLayout({
+
{children} diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx new file mode 100644 index 0000000..dd9f1ad --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -0,0 +1,64 @@ +import Link from "next/link" +import { ClipboardList, FileText, PackageX, ShoppingCart, type LucideIcon } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [ + { + title: "Requisitions", + description: "Raise a purchase requisition and submit it into procurement.", + href: "/dashboard/procurement/requisitions", + icon: ClipboardList, + }, + { + title: "RFQs", + description: "Request quotations from vendors, record pricing, and compare side by side.", + href: "/dashboard/procurement/rfqs", + icon: FileText, + }, + { + title: "Purchase Orders", + description: "Auto-approved on creation, freely editable while open, cancellable before receipt.", + href: "/dashboard/procurement/purchase-orders", + icon: ShoppingCart, + }, + { + title: "Purchase Returns", + description: "Return received goods to a vendor, referencing the original GRN line.", + href: "/dashboard/procurement/purchase-returns", + icon: PackageX, + }, +] + +export default function ProcurementHubPage() { + return ( +
+
+

Procurement

+

+ Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09). +

+
+ +
+ {areas.map((area) => ( + + + +
+
+ +
+ {area.title} +
+
+ +

{area.description}

+
+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx new file mode 100644 index 0000000..8b0be76 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -0,0 +1,459 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react" + +import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" +import { warehousesApi } from "@/lib/api/warehouses" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { vendorsApi } from "@/lib/api/vendors" +import { ApiError } from "@/lib/api-client" +import { errorMessage } from "@/lib/error-map" +import { validatePoLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement" +import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import { PoStatusBadge } from "@/components/procurement/status-badges" + +interface DraftLine { + key: string + poLineId: number | null + itemId: number | null + uomId: number | null + warehouseId: number | null + qty: string + unitPrice: string + tax: string + qtyReceived: number +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `poeditline-${keySeq}` +} + +export default function PurchaseOrderDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const poId = Number(params.id) + + const [po, setPo] = useState(null) + const [etag, setEtag] = useState(null) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [vendors, setVendors] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [lines, setLines] = useState([]) + const [lineErrors, setLineErrors] = useState>>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + + const [showCancelForm, setShowCancelForm] = useState(false) + const [cancelReason, setCancelReason] = useState("") + const [cancelling, setCancelling] = useState(false) + + function toDraftLines(order: PurchaseOrder): DraftLine[] { + return order.lines.map((l) => ({ + key: newKey(), + poLineId: l.poLineId, + itemId: l.itemId, + uomId: l.uomId, + warehouseId: l.warehouseId, + qty: String(l.qty), + unitPrice: String(l.unitPrice), + tax: String(l.tax), + qtyReceived: l.qtyReceived, + })) + } + + function load() { + setLoadError(null) + purchaseOrdersApi + .getWithETag(poId) + .then(({ data, etag: tag }) => { + setPo(data) + setEtag(tag) + setLines(toDraftLines(data)) + setConflict(false) + }) + .catch((err) => setLoadError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(poId)) return + load() + Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list(), warehousesApi.list(), vendorsApi.list({ pageSize: 200 })]) + .then(([it, uo, wh, ve]) => { + setItems(it.items) + setUoms(uo.items) + setWarehouses(wh.items) + setVendors(ve.items) + }) + .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [poId]) + + function itemFor(itemId: number | null) { + return items.find((i) => i.itemId === itemId) ?? null + } + function uomName(uomId: number) { + return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` + } + function warehouseCode(warehouseId: number) { + return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}` + } + function vendorCode(vendorId: number) { + return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` + } + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSave() { + if (!po || !etag) return + setSaveError(null) + + if (lines.length === 0) { + setSaveError("A purchase order needs at least one line.") + return + } + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validatePoLine({ + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + unitPrice: line.unitPrice, + tax: line.tax, + }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSaveError("Fix the highlighted lines before saving.") + return + } + + const payloadLines: CreatePoLineInput[] = lines.map((l) => ({ + itemId: l.itemId as number, + uomId: l.uomId as number, + warehouseId: l.warehouseId as number, + qty: Number(l.qty), + unitPrice: Number(l.unitPrice), + tax: Number(l.tax), + })) + + setSaving(true) + try { + const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag) + setPo(result.data) + setEtag(result.etag) + setLines(toDraftLines(result.data)) + toast.success("Purchase order saved", `${result.data.docNo} updated (FR-PROC-05, edit-while-open).`) + } catch (err) { + const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code + if (code === "CONCURRENCY_CONFLICT") { + setConflict(true) + setSaveError(errorMessage(err)) + setSaving(false) + return + } + setSaveError(errorMessage(err)) + toast.error("Could not save purchase order", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleCancel() { + if (!po) return + if (!cancelReason.trim()) { + setSaveError("A cancellation reason is required.") + return + } + setCancelling(true) + try { + const updated = await purchaseOrdersApi.cancel(po.poId, { reason: cancelReason.trim() }) + setPo(updated) + setShowCancelForm(false) + toast.success("Purchase order cancelled", updated.docNo) + } catch (err) { + toast.error("Could not cancel purchase order", errorMessage(err)) + } finally { + setCancelling(false) + } + } + + if (loadError && !po) { + return ( +
+
{loadError}
+ + Back to purchase orders + +
+ ) + } + + if (!po) { + return ( +
+ + +
+ ) + } + + const editable = isPoEditable(po.status) && !conflict + const hasReceipts = po.lines.some((l) => l.qtyReceived > 0) + + return ( +
+
+
+ + + +
+
+

{po.docNo}

+ +
+

+ Vendor {vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)} +

+
+
+ + {isPoEditable(po.status) && !showCancelForm && ( + + )} +
+ + {showCancelForm && ( +
+

Cancel {po.docNo}

+ setCancelReason(e.target.value)} + placeholder="Reason (e.g. Duplicate order)" + className="h-11 max-w-md text-base" + /> +
+ + +
+
+ )} + + {conflict && ( +
+ +
+

{saveError ?? "This purchase order was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+
+

Lines

+ {editable && ( + + )} +
+ +
+ + + + Item + UOM + Warehouse + Qty + Received + Unit price + Tax + {editable && } + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + if (!editable) { + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.uomId ? uomName(line.uomId) : "—"} + {line.warehouseId ? warehouseCode(line.warehouseId) : "—"} + {line.qty} + {line.qtyReceived} + {Number(line.unitPrice).toFixed(2)} + {(Number(line.tax) * 100).toFixed(0)}% + + ) + } + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {items.map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {warehouses.map((w) => ( + + {w.code} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + {line.qtyReceived} + + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { tax: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ) + })} + +
+
+
+ + {editable && ( +
+ + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx new file mode 100644 index 0000000..06d58ae --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -0,0 +1,411 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { purchaseOrdersApi } from "@/lib/api/purchase-orders" +import { requisitionsApi } from "@/lib/api/requisitions" +import { rfqsApi } from "@/lib/api/rfqs" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage } from "@/lib/error-map" +import { validatePoLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreatePoLineInput } from "@/types/procurement" +import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + itemId: number | null + uomId: number | null + warehouseId: number | null + qty: string + unitPrice: string + tax: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `poline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" } +} + +function NewPurchaseOrderContent() { + const router = useRouter() + const searchParams = useSearchParams() + const requisitionId = Number(searchParams.get("requisitionId")) || null + const rfqId = Number(searchParams.get("rfqId")) || null + const rfqVendorId = Number(searchParams.get("vendorId")) || null + + const [items, setItems] = useState(null) + const [uoms, setUoms] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [vendors, setVendors] = useState(null) + const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId) + const [loadError, setLoadError] = useState(null) + + const [vendorId, setVendorId] = useState(rfqVendorId) + const [lines, setLines] = useState([emptyLine()]) + + const [headerError, setHeaderError] = useState(null) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([ + itemsApi.list({ pageSize: 200, status: "Active" }), + uomsApi.list(), + warehousesApi.list(), + vendorsApi.list({ pageSize: 200, status: "Active" }), + ]) + .then(([it, uo, wh, ve]) => { + setItems(it.items) + setUoms(uo.items) + setWarehouses(wh.items) + setVendors(ve.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + useEffect(() => { + if (requisitionId) { + requisitionsApi + .get(requisitionId) + .then((req) => { + setLines( + req.lines.map( + (l): DraftLine => ({ + key: newKey(), + itemId: l.itemId, + uomId: null, + warehouseId: null, + qty: String(l.qty), + unitPrice: "", + tax: "0.18", + }) + ) + ) + }) + .catch((err) => setHeaderError(errorMessage(err))) + .finally(() => setPrefillLoading(false)) + return + } + + if (rfqId && rfqVendorId) { + Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)]) + .then(([rfq, comparison]) => { + setVendorId(rfqVendorId) + setLines( + rfq.lines.map((l): DraftLine => { + const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId) + return { + key: newKey(), + itemId: l.itemId, + uomId: null, + warehouseId: null, + qty: String(l.qty), + unitPrice: cell ? String(cell.unitPrice) : "", + tax: "0.18", + } + }) + ) + }) + .catch((err) => setHeaderError(errorMessage(err))) + .finally(() => setPrefillLoading(false)) + return + } + + setPrefillLoading(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requisitionId, rfqId, rfqVendorId]) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + function itemFor(itemId: number | null) { + return items?.find((i) => i.itemId === itemId) ?? null + } + + async function handleSubmit() { + setHeaderError(null) + setSubmitError(null) + + if (!vendorId) { + setHeaderError("Select a vendor.") + return + } + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validatePoLine({ + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + unitPrice: line.unitPrice, + tax: line.tax, + }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreatePoLineInput[] = lines.map((l) => ({ + itemId: l.itemId as number, + uomId: l.uomId as number, + warehouseId: l.warehouseId as number, + qty: Number(l.qty), + unitPrice: Number(l.unitPrice), + tax: Number(l.tax), + })) + + setSubmitting(true) + try { + const { data: po } = await purchaseOrdersApi.create({ + vendorId, + requisitionId: requisitionId ?? (rfqId ? undefined : null), + lines: payloadLines, + }) + toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`) + router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not create purchase order", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !items || !uoms || !warehouses || !vendors || prefillLoading + + return ( +
+
+ + + +
+

New Purchase Order

+

Auto-approved on creation; freely editable while open (FR-PROC-03..05).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + value={vendorId} onValueChange={setVendorId}> + + + + + {(vendors ?? []).map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+ {requisitionId && ( +
From Requisition #{requisitionId}
+ )} + {rfqId &&
From RFQ #{rfqId}
} +
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ +
+ + {lines.length > 0 && ( +
+ + + + Item + UOM + Warehouse + Qty + Unit price + Tax + + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( + + + {requisitionId || rfqId ? ( +
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + )} +
+ + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + + + + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {(warehouses ?? []).map((w) => ( + + {w.code} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { tax: e.target.value })} + className="h-11 text-base" + /> + + + + + +
+ ) + })} +
+
+
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewPurchaseOrderPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx new file mode 100644 index 0000000..d399fa2 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react" + +import { purchaseOrdersApi } from "@/lib/api/purchase-orders" +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage } from "@/lib/error-map" +import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement" +import { Vendor } from "@/types/master-data" +import { PaginationMeta } from "@/types/common" +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { PoStatusBadge } from "@/components/procurement/status-badges" + +type StatusFilter = PurchaseOrderStatus | "All" + +const PAGE_SIZE = 10 + +export default function PurchaseOrdersListPage() { + const [pos, setPos] = useState(null) + const [vendors, setVendors] = useState([]) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status]) + + function load() { + setError(null) + purchaseOrdersApi + .list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status }) + .then((res) => { + setPos(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page, query, status]) + useEffect(() => { + vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) + }, []) + + function vendorCode(vendorId: number) { + return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` + } + + const hasFilters = query.length > 0 || status !== "All" + + return ( +
+
+
+

Purchase Orders

+

Auto-approved on creation and freely editable while open (FR-PROC-03..05).

+
+ + + New PO + +
+ +
+ setSearchInput(e.target.value)} + placeholder="Search doc no., vendor…" + className="h-12 w-full flex-1 basis-0 text-base" + aria-label="Search purchase orders" + /> + + {hasFilters && ( + + )} +
+ + {error && ( +
{error}
+ )} + + {!error && pos === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && pos !== null && pos.length === 0 && ( +
+ +

{hasFilters ? "No purchase orders match your search/filter." : "No purchase orders yet."}

+
+ )} + + {!error && pos !== null && pos.length > 0 && ( + <> + + + + Doc No + Vendor + Status + Grand total + Created + + + + {pos.map((po) => ( + + + + {po.docNo} + + + {vendorCode(po.vendorId)} + + + + {po.totals.currency} {po.totals.grandTotal.toFixed(2)} + {new Date(po.createdAt).toLocaleString()} + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx new file mode 100644 index 0000000..324cfe1 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -0,0 +1,286 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft } from "lucide-react" + +import { purchaseReturnsApi } from "@/lib/api/purchase-returns" +import { grnsApi } from "@/lib/api/grns" +import { reasonCodesApi } from "@/lib/api/reason-codes" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateReturnLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreatePurchaseReturnLineInput } from "@/types/procurement" +import { Grn, GrnLine } from "@/types/grn" +import { ItemListItem } from "@/types/master-data" +import { ReasonCode } from "@/types/stock" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import { HoldStatusBadge } from "@/components/receiving/status-badges" + +interface LineState { + selected: boolean + qty: string +} + +function NewPurchaseReturnContent() { + const router = useRouter() + const searchParams = useSearchParams() + const presetGrnId = Number(searchParams.get("grnId")) || null + const presetGrnLineId = Number(searchParams.get("grnLineId")) || null + + const [grns, setGrns] = useState(null) + const [items, setItems] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [grnId, setGrnId] = useState(presetGrnId) + const [reasonCodeId, setReasonCodeId] = useState(null) + const [lineState, setLineState] = useState>({}) + const [lineErrors, setLineErrors] = useState>>({}) + + const [headerError, setHeaderError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")]) + .then(([grnList, it, rc]) => { + // Only Confirmed/Closed GRNs have posted stock layers to return against. + Promise.all(grnList.items.filter((g) => g.status === "Confirmed" || g.status === "Closed").map((g) => grnsApi.get(g.grnId))).then(setGrns) + setItems(it.items) + setReasonCodes(rc.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null + + useEffect(() => { + if (!selectedGrn) { + setLineState({}) + return + } + const next: Record = {} + for (const line of selectedGrn.lines) { + next[line.grnLineId] = { + selected: presetGrnLineId ? line.grnLineId === presetGrnLineId : false, + qty: presetGrnLineId && line.grnLineId === presetGrnLineId ? String(line.qty) : "", + } + } + setLineState(next) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedGrn?.grnId]) + + function itemFor(itemId: number) { + return items.find((i) => i.itemId === itemId) + } + + function toggleLine(line: GrnLine) { + setLineState((prev) => ({ + ...prev, + [line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) }, + })) + } + + function setQty(grnLineId: number, qty: string) { + setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } })) + } + + async function handleSubmit() { + setHeaderError(null) + setSubmitError(null) + + if (!selectedGrn) { + setHeaderError("Select a GRN to return against.") + return + } + if (!reasonCodeId) { + setHeaderError("Select a reason code.") + return + } + + const selectedLines = selectedGrn.lines.filter((l) => lineState[l.grnLineId]?.selected) + if (selectedLines.length === 0) { + setSubmitError("Select at least one line to return.") + return + } + + const nextErrors: Record> = {} + for (const line of selectedLines) { + const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty }) + if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors + } + setLineErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreatePurchaseReturnLineInput[] = selectedLines.map((l) => ({ + grnLineId: l.grnLineId, + itemId: l.itemId, + qty: Number(lineState[l.grnLineId].qty), + })) + + setSubmitting(true) + try { + const purchaseReturn = await purchaseReturnsApi.create({ + vendorId: selectedGrn.vendorId, + warehouseId: selectedGrn.warehouseId, + reasonCodeId, + lines: payloadLines, + }) + toast.success("Purchase return posted", `${purchaseReturn.docNo} — ${purchaseReturn.ledgerRefs.length} ledger entr${purchaseReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`) + router.push("/dashboard/procurement/purchase-returns") + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not post purchase return", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !grns + + return ( +
+
+ + + +
+

New Purchase Return

+

Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}> + + + + + {(grns ?? []).map((g) => ( + + {g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId} + + ))} + + +
+
+ + value={reasonCodeId} onValueChange={setReasonCodeId}> + + + + + {reasonCodes.map((rc) => ( + + {rc.description} + + ))} + + +
+
+ + {headerError && ( +
{headerError}
+ )} + + {selectedGrn && ( +
+

Lines received on {selectedGrn.docNo}

+ + + + + Item + Received qty + Hold status + Return qty + + + + {selectedGrn.lines.map((line) => { + const item = itemFor(line.itemId) + const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } + const errors = lineErrors[line.grnLineId] ?? {} + return ( + + + toggleLine(line)} /> + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + + + + + setQty(line.grnLineId, e.target.value)} + className="h-11 text-base" + /> + + + + ) + })} + +
+
+ )} + + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewPurchaseReturnPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx new file mode 100644 index 0000000..62a0694 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { PackageX, Plus } from "lucide-react" + +import { purchaseReturnsApi } from "@/lib/api/purchase-returns" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" +import { reasonCodesApi } from "@/lib/api/reason-codes" +import { errorMessage } from "@/lib/error-map" +import { PurchaseReturnSummary } from "@/types/procurement" +import { Vendor, Warehouse } from "@/types/master-data" +import { ReasonCode } from "@/types/stock" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +export default function PurchaseReturnsListPage() { + const [returns, setReturns] = useState(null) + const [vendors, setVendors] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) + .then(([r, v, w, rc]) => { + setReturns(r.items) + setVendors(v.items) + setWarehouses(w.items) + setReasonCodes(rc.items) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + function vendorCode(id: number) { + return vendors.find((v) => v.vendorId === id)?.code ?? `#${id}` + } + function warehouseCode(id: number) { + return warehouses.find((w) => w.warehouseId === id)?.code ?? `#${id}` + } + function reasonLabel(id: number) { + return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` + } + + return ( +
+
+
+

Purchase Returns

+

Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).

+
+ + + New Return + +
+ + {error && ( +
{error}
+ )} + + {!error && returns === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && returns !== null && returns.length === 0 && ( +
+ +

No purchase returns yet.

+ + + New Return + +
+ )} + + {!error && returns !== null && returns.length > 0 && ( + + + + Doc No + Vendor + Warehouse + Reason + Status + Created + + + + {returns.map((r) => ( + + {r.docNo} + {vendorCode(r.vendorId)} + {warehouseCode(r.warehouseId)} + {reasonLabel(r.reasonCodeId)} + + + {r.status} + + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx new file mode 100644 index 0000000..1dbf471 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx @@ -0,0 +1,144 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react" + +import { requisitionsApi } from "@/lib/api/requisitions" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { Requisition } from "@/types/procurement" +import { ItemListItem } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" +import { RequisitionStatusBadge } from "@/components/procurement/status-badges" + +export default function RequisitionDetailPage() { + const params = useParams<{ id: string }>() + const requisitionId = Number(params.id) + + const [requisition, setRequisition] = useState(null) + const [items, setItems] = useState([]) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function load() { + setError(null) + requisitionsApi.get(requisitionId).then(setRequisition).catch((err) => setError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(requisitionId)) return + load() + itemsApi.list({ pageSize: 200 }).then((res) => setItems(res.items)).catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requisitionId]) + + function itemFor(itemId: number) { + return items.find((i) => i.itemId === itemId) + } + + async function handleSubmit() { + if (!requisition) return + setSubmitting(true) + try { + const updated = await requisitionsApi.submit(requisition.requisitionId) + setRequisition(updated) + toast.success("Requisition submitted", `${updated.docNo} is ready for RFQ or a direct PO.`) + } catch (err) { + toast.error("Could not submit requisition", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + if (error && !requisition) { + return
{error}
+ } + + if (!requisition) { + return ( +
+ + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{requisition.docNo}

+ +
+

Requested by #{requisition.requestedBy} — {new Date(requisition.createdAt).toLocaleString()}

+
+
+ +
+ {requisition.status === "Draft" && ( + + )} + {requisition.status === "Submitted" && ( + <> + + + Create RFQ + + + + Create PO + + + )} +
+
+ + {error && ( +
{error}
+ )} + + + + + Item + Qty + Required by + + + + {requisition.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + {line.requiredBy} + + ) + })} + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx new file mode 100644 index 0000000..a6143d3 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx @@ -0,0 +1,213 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { requisitionsApi } from "@/lib/api/requisitions" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateRequisitionLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreateReqLineInput } from "@/types/procurement" +import { ItemListItem } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + itemId: number | null + qty: string + requiredBy: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `rline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { key: newKey(), itemId: null, qty: "", requiredBy: "" } +} + +export default function NewRequisitionPage() { + const router = useRouter() + + const [items, setItems] = useState(null) + const [loadError, setLoadError] = useState(null) + const [lines, setLines] = useState([emptyLine()]) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err))) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSubmit() { + setSubmitError(null) + + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validateRequisitionLine({ itemId: line.itemId, qty: line.qty, requiredBy: line.requiredBy }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreateReqLineInput[] = lines.map((l) => ({ + itemId: l.itemId as number, + qty: Number(l.qty), + requiredBy: l.requiredBy, + })) + + setSubmitting(true) + try { + const requisition = await requisitionsApi.create({ lines: payloadLines }) + toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`) + router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not create requisition", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !items + + return ( +
+
+ + + +
+

New Requisition

+

Request items for procurement; submit once the lines are ready (FR-PROC-01).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+

Lines

+ +
+ + {lines.length > 0 && ( + + + + Item + Qty + Required by + + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { requiredBy: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ) + })} + +
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx new file mode 100644 index 0000000..27d444c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx @@ -0,0 +1,151 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ClipboardList, Plus } from "lucide-react" + +import { requisitionsApi } from "@/lib/api/requisitions" +import { errorMessage } from "@/lib/error-map" +import { RequisitionStatus, RequisitionSummary } from "@/types/procurement" +import { PaginationMeta } from "@/types/common" +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { RequisitionStatusBadge } from "@/components/procurement/status-badges" + +type StatusFilter = RequisitionStatus | "All" + +const PAGE_SIZE = 10 + +export default function RequisitionsListPage() { + const [requisitions, setRequisitions] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [page, setPage] = useState(1) + + useEffect(() => setPage(1), [status]) + + function load() { + setError(null) + requisitionsApi + .list({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status }) + .then((res) => { + setRequisitions(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page, status]) + + return ( +
+
+
+

Requisitions

+

Raise a purchase requisition and submit it into procurement (FR-PROC-01).

+
+ + + New Requisition + +
+ +
+ +
+ + {error && ( +
{error}
+ )} + + {!error && requisitions === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && requisitions !== null && requisitions.length === 0 && ( +
+ +

No requisitions yet.

+ + + New Requisition + +
+ )} + + {!error && requisitions !== null && requisitions.length > 0 && ( + <> + + + + Doc No + Status + Lines + Requested by + Created + + + + {requisitions.map((r) => ( + + + + {r.docNo} + + + + + + {r.lineCount} + #{r.requestedBy} + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx new file mode 100644 index 0000000..a60d775 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -0,0 +1,332 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, ShoppingCart } from "lucide-react" + +import { rfqsApi } from "@/lib/api/rfqs" +import { vendorsApi } from "@/lib/api/vendors" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateQuotationLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { QuotationLine, Rfq, RfqComparison } from "@/types/procurement" +import { ItemListItem, Vendor } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import { RfqStatusBadge } from "@/components/procurement/status-badges" + +interface QuoteDraft { + unitPrice: string + leadDays: string +} + +export default function RfqDetailPage() { + const params = useParams<{ id: string }>() + const rfqId = Number(params.id) + + const [rfq, setRfq] = useState(null) + const [comparison, setComparison] = useState(null) + const [items, setItems] = useState([]) + const [vendors, setVendors] = useState([]) + const [error, setError] = useState(null) + + const [quoteVendorId, setQuoteVendorId] = useState(null) + const [quoteLines, setQuoteLines] = useState>({}) + const [quoteErrors, setQuoteErrors] = useState>>({}) + const [quoteFormError, setQuoteFormError] = useState(null) + const [submittingQuote, setSubmittingQuote] = useState(false) + + function load() { + setError(null) + Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)]) + .then(([r, c]) => { + setRfq(r) + setComparison(c) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(rfqId)) return + load() + Promise.all([itemsApi.list({ pageSize: 200 }), vendorsApi.list({ pageSize: 200 })]) + .then(([it, ve]) => { + setItems(it.items) + setVendors(ve.items) + }) + .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rfqId]) + + const quotedVendorIds = useMemo(() => { + const set = new Set() + for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId) + return set + }, [comparison]) + + const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds]) + + function itemFor(itemId: number) { + return items.find((i) => i.itemId === itemId) + } + function vendorFor(vendorId: number) { + return vendors.find((v) => v.vendorId === vendorId) + } + + function selectQuoteVendor(vendorId: number | null) { + setQuoteVendorId(vendorId) + setQuoteFormError(null) + setQuoteErrors({}) + if (!rfq) return + const draft: Record = {} + for (const line of rfq.lines) draft[line.itemId] = { unitPrice: "", leadDays: "" } + setQuoteLines(draft) + } + + async function handleSubmitQuote() { + if (!rfq || !quoteVendorId) { + setQuoteFormError("Select a vendor first.") + return + } + const nextErrors: Record> = {} + for (const line of rfq.lines) { + const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" } + const errors = validateQuotationLine(draft) + if (Object.keys(errors).length > 0) nextErrors[line.itemId] = errors + } + setQuoteErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setQuoteFormError("Fix the highlighted fields before submitting.") + return + } + + const lines: QuotationLine[] = rfq.lines.map((l) => ({ + itemId: l.itemId, + unitPrice: Number(quoteLines[l.itemId].unitPrice), + leadDays: Number(quoteLines[l.itemId].leadDays), + })) + + setSubmittingQuote(true) + try { + await rfqsApi.addQuotation(rfqId, { vendorId: quoteVendorId, lines }) + toast.success("Quotation recorded", `${vendorFor(quoteVendorId)?.code ?? `Vendor #${quoteVendorId}`} priced ${lines.length} line(s).`) + setQuoteVendorId(null) + setQuoteLines({}) + const c = await rfqsApi.comparison(rfqId) + setComparison(c) + } catch (err) { + setQuoteFormError(errorMessage(err)) + toast.error("Could not record quotation", errorMessage(err)) + } finally { + setSubmittingQuote(false) + } + } + + if (error && !rfq) { + return
{error}
+ } + + if (!rfq || !comparison) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + + +
+
+

{rfq.docNo}

+ +
+

+ {rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""} + Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")} +

+
+
+ + {error && ( +
{error}
+ )} + +
+

Lines

+ + + + Item + Qty + + + + {rfq.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + + ) + })} + +
+
+ +
+

Vendor comparison

+ {comparison.lines.every((l) => l.cells.length === 0) ? ( +

No quotations recorded yet.

+ ) : ( +
+ + + + Item + {rfq.vendorIds.map((vid) => ( + {vendorFor(vid)?.code ?? `#${vid}`} + ))} + + + + {comparison.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {rfq.vendorIds.map((vid) => { + const cell = line.cells.find((c) => c.vendorId === vid) + return ( + + {cell ? ( + + {cell.unitPrice.toFixed(2)} ({cell.leadDays}d) + + ) : ( + + )} + + ) + })} + + ) + })} + +
+
+ )} + + {[...quotedVendorIds].length > 0 && ( +
+ {[...quotedVendorIds].map((vid) => ( + + + Create PO from {vendorFor(vid)?.code ?? `#${vid}`} + + ))} +
+ )} +
+ + {pendingVendors.length > 0 && ( +
+

Record a quotation

+ +
+ + value={quoteVendorId} onValueChange={selectQuoteVendor}> + + + + + {pendingVendors.map((vid) => ( + + {vendorFor(vid)?.code ?? `#${vid}`} — {vendorFor(vid)?.name} + + ))} + + +
+ + {quoteVendorId && ( + + + + Item + Unit price + Lead days + + + + {rfq.lines.map((line) => { + const item = itemFor(line.itemId) + const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" } + const errors = quoteErrors[line.itemId] ?? {} + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + + setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], unitPrice: e.target.value } }))} + className="h-11 text-base" + /> + + + + setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], leadDays: e.target.value } }))} + className="h-11 text-base" + /> + + + + ) + })} + +
+ )} + + {quoteFormError && ( +
{quoteFormError}
+ )} + +
+ +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx new file mode 100644 index 0000000..ee05136 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -0,0 +1,280 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { rfqsApi } from "@/lib/api/rfqs" +import { requisitionsApi } from "@/lib/api/requisitions" +import { vendorsApi } from "@/lib/api/vendors" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateRfqLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreateRfqLineInput } from "@/types/procurement" +import { ItemListItem, Vendor } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + itemId: number | null + qty: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `rfqline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { key: newKey(), itemId: null, qty: "" } +} + +function NewRfqContent() { + const router = useRouter() + const searchParams = useSearchParams() + const requisitionId = Number(searchParams.get("requisitionId")) || null + + const [items, setItems] = useState(null) + const [vendors, setVendors] = useState(null) + const [loadError, setLoadError] = useState(null) + const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionId) + + const [vendorIds, setVendorIds] = useState>(new Set()) + const [lines, setLines] = useState([emptyLine()]) + const [lineErrors, setLineErrors] = useState>>({}) + const [headerError, setHeaderError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), vendorsApi.list({ pageSize: 200, status: "Active" })]) + .then(([it, ve]) => { + setItems(it.items) + setVendors(ve.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + useEffect(() => { + if (!requisitionId) return + requisitionsApi + .get(requisitionId) + .then((req) => { + setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) }))) + }) + .catch((err) => setHeaderError(errorMessage(err))) + .finally(() => setRequisitionLoading(false)) + }, [requisitionId]) + + function toggleVendor(vendorId: number) { + setVendorIds((prev) => { + const next = new Set(prev) + if (next.has(vendorId)) next.delete(vendorId) + else next.add(vendorId) + return next + }) + } + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSubmit() { + setHeaderError(null) + setSubmitError(null) + + if (vendorIds.size === 0) { + setHeaderError("Select at least one vendor to invite.") + return + } + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validateRfqLine({ itemId: line.itemId, qty: line.qty }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreateRfqLineInput[] = lines.map((l) => ({ itemId: l.itemId as number, qty: Number(l.qty) })) + + setSubmitting(true) + try { + const rfq = await rfqsApi.create({ requisitionId, vendorIds: [...vendorIds], lines: payloadLines }) + toast.success("RFQ created", `${rfq.docNo} sent to ${vendorIds.size} vendor(s).`) + router.push(`/dashboard/procurement/rfqs/${rfq.rfqId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not create RFQ", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + function itemFor(itemId: number | null) { + return items?.find((i) => i.itemId === itemId) ?? null + } + + const loading = !items || !vendors || requisitionLoading + + return ( +
+
+ + + +
+

New RFQ

+

+ {requisitionId ? `Request quotations for Requisition #${requisitionId}` : "Request quotations from one or more vendors (FR-PROC-02)."} +

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+ +
+ {(vendors ?? []).map((v) => ( + + ))} +
+
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ {!requisitionId && ( + + )} +
+ + {lines.length > 0 && ( + + + + Item + Qty + {!requisitionId && } + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( + + + {requisitionId ? ( +
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + )} +
+ + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + {!requisitionId && ( + + + + )} +
+ ) + })} +
+
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewRfqPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx new file mode 100644 index 0000000..6cc1f9c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { FileText, Plus } from "lucide-react" + +import { rfqsApi } from "@/lib/api/rfqs" +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage } from "@/lib/error-map" +import { RfqSummary } from "@/types/procurement" +import { Vendor } from "@/types/master-data" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { RfqStatusBadge } from "@/components/procurement/status-badges" + +export default function RfqsListPage() { + const [rfqs, setRfqs] = useState(null) + const [vendors, setVendors] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) + .then(([r, v]) => { + setRfqs(r.items) + setVendors(v.items) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + function vendorNames(vendorIds: number[]) { + return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") + } + + return ( +
+
+
+

RFQs

+

Request quotations from vendors and compare pricing (FR-PROC-02).

+
+ + + New RFQ + +
+ + {error && ( +
{error}
+ )} + + {!error && rfqs === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rfqs !== null && rfqs.length === 0 && ( +
+ +

No RFQs yet.

+ + + New RFQ + +
+ )} + + {!error && rfqs !== null && rfqs.length > 0 && ( + + + + Doc No + Requisition + Vendors invited + Status + Created + + + + {rfqs.map((r) => ( + + + + {r.docNo} + + + {r.requisitionId ? `#${r.requisitionId}` : } + {vendorNames(r.vendorIds)} + + + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx new file mode 100644 index 0000000..a319060 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -0,0 +1,592 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react" + +import { itemsApi } from "@/lib/api/items" +import { categoriesApi } from "@/lib/api/categories" +import { uomsApi } from "@/lib/api/uoms" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" +import { ApiError } from "@/lib/api-client" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data" + +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface ReorderDraft { + key: string + warehouseId: number | null + reorderPoint: string + reorderQty: string +} + +interface ConversionDraft { + key: string + fromUom: number | null + toUom: number | null + factor: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `row-${keySeq}` +} + +export default function ItemDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const itemId = Number(params.id) + + const [item, setItem] = useState(null) + const [etag, setEtag] = useState(null) + const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) + const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) + const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([]) + const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) + const [loadError, setLoadError] = useState(null) + + // Basic info form + const [sku, setSku] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [categoryId, setCategoryId] = useState(null) + const [baseUomId, setBaseUomId] = useState(null) + const [defaultVendorId, setDefaultVendorId] = useState(null) + const [itemType, setItemType] = useState("Stocked") + const [trackingMode, setTrackingMode] = useState("None") + const [taxClass, setTaxClass] = useState("") + + const [errors, setErrors] = useState>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + const [togglingStatus, setTogglingStatus] = useState(false) + + // Reorder settings + const [reorderLines, setReorderLines] = useState([]) + const [reorderErrors, setReorderErrors] = useState>>({}) + const [reorderSaveError, setReorderSaveError] = useState(null) + const [savingReorder, setSavingReorder] = useState(false) + + // UOM conversions + const [conversionLines, setConversionLines] = useState([]) + const [conversionErrors, setConversionErrors] = useState>>({}) + const [conversionSaveError, setConversionSaveError] = useState(null) + const [savingConversions, setSavingConversions] = useState(false) + + function applyItem(data: Item) { + setItem(data) + setSku(data.sku) + setName(data.name) + setDescription(data.description ?? "") + setCategoryId(data.categoryId) + setBaseUomId(data.baseUomId) + setDefaultVendorId(data.defaultVendorId) + setItemType(data.itemType) + setTrackingMode(data.trackingMode) + setTaxClass(data.taxClass ?? "") + setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) }))) + setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) + } + + function load() { + setLoadError(null) + itemsApi + .get(itemId) + .then(({ data, etag: tag }) => { + applyItem(data) + setEtag(tag) + setConflict(false) + }) + .catch((err) => setLoadError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(itemId)) return + load() + Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()]) + .then(([cat, uo, ve, wh]) => { + setCategories(cat.items) + setUoms(uo.items) + setVendors(ve.items) + setWarehouses(wh.items) + }) + .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [itemId]) + + async function handleSave() { + if (!item || !etag) return + setSaveError(null) + const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSaving(true) + try { + const result = await itemsApi.update( + item.itemId, + { sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null }, + etag + ) + applyItem(result.data) + setEtag(result.etag) + toast.success("Item saved", `${result.data.sku} — ${result.data.name}`) + } catch (err) { + const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code + if (code === "CONCURRENCY_CONFLICT") { + setConflict(true) + setSaveError(errorMessage(err)) + setSaving(false) + return + } + const fe = fieldErrors(err) + if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku })) + setSaveError(errorMessage(err)) + toast.error("Could not save item", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleToggleStatus() { + if (!item) return + const next = item.status === "Active" ? "Inactive" : "Active" + setTogglingStatus(true) + try { + await itemsApi.updateStatus(item.itemId, next) + toast.success(next === "Active" ? "Item activated" : "Item deactivated") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setTogglingStatus(false) + } + } + + function updateReorderLine(key: string, patch: Partial) { + setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + function removeReorderLine(key: string) { + setReorderLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSaveReorder() { + if (!item) return + setReorderSaveError(null) + const nextErrors: Record> = {} + for (const line of reorderLines) { + const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty }) + if (Object.keys(errs).length > 0) nextErrors[line.key] = errs + } + setReorderErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setReorderSaveError("Fix the highlighted rows before saving.") + return + } + + const settings: ItemReorderSetting[] = reorderLines.map((l) => ({ + warehouseId: l.warehouseId as number, + reorderPoint: Number(l.reorderPoint), + reorderQty: Number(l.reorderQty), + })) + + setSavingReorder(true) + try { + const result = await itemsApi.updateReorder(item.itemId, { settings }) + setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev)) + toast.success("Reorder settings saved") + } catch (err) { + setReorderSaveError(errorMessage(err)) + toast.error("Could not save reorder settings", errorMessage(err)) + } finally { + setSavingReorder(false) + } + } + + function updateConversionLine(key: string, patch: Partial) { + setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + function removeConversionLine(key: string) { + setConversionLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSaveConversions() { + if (!item) return + setConversionSaveError(null) + const nextErrors: Record> = {} + for (const line of conversionLines) { + const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor }) + if (Object.keys(errs).length > 0) nextErrors[line.key] = errs + } + setConversionErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setConversionSaveError("Fix the highlighted rows before saving.") + return + } + + const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) })) + + setSavingConversions(true) + try { + const result = await itemsApi.updateUomConversions(item.itemId, { conversions }) + setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev)) + setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) + toast.success("UOM conversions saved") + } catch (err) { + setConversionSaveError(errorMessage(err)) + toast.error("Could not save UOM conversions", errorMessage(err)) + } finally { + setSavingConversions(false) + } + } + + function uomName(uomId: number) { + return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` + } + + if (loadError && !item) { + return ( +
+
{loadError}
+ + Back to items + +
+ ) + } + + if (!item) { + return ( +
+ + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{item.sku}

+ + {item.status} + +
+

{item.name}

+
+
+ + +
+ + {conflict && ( +
+ +
+

{saveError ?? "This item was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+

Basic info

+
+
+ + setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + value={categoryId} onValueChange={setCategoryId} disabled={conflict}> + + + + + {categories.map((c) => ( + + {c.name} + + ))} + + + +
+
+ + value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + +
+
+ + value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}> + + + + + {vendors.map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+
+ + setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}> + + + + + Stocked + Non-stocked + Service + + +
+
+ + value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}> + + + + + None + Batch + Serial + + +
+
+
+ + +
+
+ +
+
+
+

Reorder settings

+

Per-warehouse reorder point and quantity (FR-MD-05).

+
+ +
+ + {reorderLines.length > 0 && ( + + + + Warehouse + Reorder point + Reorder qty + + + + + {reorderLines.map((line) => { + const errs = reorderErrors[line.key] ?? {} + return ( + + + value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}> + + + + + {warehouses.map((w) => ( + + {w.code} + + ))} + + + + + + updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" /> + + + + updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" /> + + + + + + + ) + })} + +
+ )} + + {reorderSaveError && ( +
{reorderSaveError}
+ )} + +
+ +
+
+ +
+
+
+

UOM conversions

+

Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).

+
+ +
+ + {conversionLines.length > 0 && ( + + + + From UOM + To UOM + Factor + + + + + {conversionLines.map((line) => { + const errs = conversionErrors[line.key] ?? {} + return ( + + + value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" /> + + + + + + + ) + })} + +
+ )} + + {conversionSaveError && ( +
{conversionSaveError}
+ )} + +
+ +
+
+ +

+ {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."} +

+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx new file mode 100644 index 0000000..19037b4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -0,0 +1,166 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, ListTree, Plus } from "lucide-react" + +import { categoriesApi } from "@/lib/api/categories" +import { errorMessage } from "@/lib/error-map" +import { validateCategoryName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Category, CategoryTreeNode } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) { + return ( +
+
+ + {node.name} + #{node.categoryId} +
+ {node.children.map((child) => ( + + ))} +
+ ) +} + +export default function CategoriesPage() { + const [tree, setTree] = useState(null) + const [flat, setFlat] = useState([]) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [parentId, setParentId] = useState(null) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + setError(null) + Promise.all([categoriesApi.tree(), categoriesApi.list()]) + .then(([t, f]) => { + setTree(t) + setFlat(f.items) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + async function handleCreate() { + const nextErrors = validateCategoryName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const category = await categoriesApi.create({ name, parentId }) + toast.success("Category created", category.name) + setOpen(false) + setName("") + setParentId(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error("Could not create category", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+ + + +
+

Categories

+

Hierarchical item category structure (FR-MD-04).

+
+
+ + + New Category} /> + + + New category + Optionally nest it under an existing category. + + + + Name + setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> + + + + Parent (optional) + value={parentId} onValueChange={setParentId}> + + + + + {flat.map((c) => ( + + {c.name} + + ))} + + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && tree === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && tree !== null && tree.length === 0 && ( +
+ +

No categories yet.

+
+ )} + + {!error && tree !== null && tree.length > 0 && ( +
+ {tree.map((node) => ( + + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx new file mode 100644 index 0000000..eda1169 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft } from "lucide-react" + +import { itemsApi } from "@/lib/api/items" +import { categoriesApi } from "@/lib/api/categories" +import { uomsApi } from "@/lib/api/uoms" +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateItemForm } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { ItemType, TrackingMode } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +export default function NewItemPage() { + const router = useRouter() + + const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null) + const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null) + const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null) + const [loadError, setLoadError] = useState(null) + + const [sku, setSku] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [categoryId, setCategoryId] = useState(null) + const [baseUomId, setBaseUomId] = useState(null) + const [defaultVendorId, setDefaultVendorId] = useState(null) + const [itemType, setItemType] = useState("Stocked") + const [trackingMode, setTrackingMode] = useState("None") + const [taxClass, setTaxClass] = useState("STD") + + const [errors, setErrors] = useState>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })]) + .then(([cat, uo, ve]) => { + setCategories(cat.items) + setUoms(uo.items) + setVendors(ve.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + async function handleSubmit() { + setSubmitError(null) + const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const { data: item } = await itemsApi.create({ + sku, + name, + description: description || null, + categoryId: categoryId as number, + baseUomId: baseUomId as number, + defaultVendorId, + itemType, + trackingMode, + taxClass: taxClass || null, + }) + toast.success("Item created", `${item.sku} — ${item.name}`) + router.push(`/dashboard/products/${item.itemId}`) + } catch (err) { + const fe = fieldErrors(err) + if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku })) + setSubmitError(errorMessage(err)) + toast.error("Could not create item", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !categories || !uoms || !vendors + + return ( +
+
+ + + +
+

New Item

+

SKU, category, base UOM, item type, and tracking mode (FR-MD-01).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" /> + +
+
+ + setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" /> + +
+
+ + setDescription(e.target.value)} className="h-12 text-base" /> +
+
+ + value={categoryId} onValueChange={setCategoryId}> + + + + + {(categories ?? []).map((c) => ( + + {c.name} + + ))} + + + +
+
+ + value={baseUomId} onValueChange={setBaseUomId}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + +
+
+ + value={defaultVendorId} onValueChange={setDefaultVendorId}> + + + + + {(vendors ?? []).map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+
+ + setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" /> +
+
+ + value={itemType} onValueChange={(v) => v && setItemType(v)}> + + + + + Stocked + Non-stocked + Service + + +
+
+ + value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> + + + + + None + Batch + Serial + + +
+
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index 84969ca..ce888b2 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -1,7 +1,250 @@ -export default function ProductsPage() { +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react" + +import { itemsApi } from "@/lib/api/items" +import { categoriesApi } from "@/lib/api/categories" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { EntityStatus, PaginationMeta } from "@/types/common" +import { Category, ItemListItem, TrackingMode } from "@/types/master-data" + +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +type StatusFilter = EntityStatus | "All" +type TrackingFilter = TrackingMode | "All" + +const PAGE_SIZE = 10 + +export default function ItemsPage() { + const [items, setItems] = useState(null) + const [categories, setCategories] = useState([]) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [categoryId, setCategoryId] = useState("All") + const [trackingMode, setTrackingMode] = useState("All") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status, categoryId, trackingMode]) + + function load() { + setError(null) + itemsApi + .list({ + page, + pageSize: PAGE_SIZE, + q: query || undefined, + status: status === "All" ? undefined : status, + categoryId: categoryId === "All" ? undefined : categoryId, + trackingMode: trackingMode === "All" ? undefined : trackingMode, + }) + .then((res) => { + setItems(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page, query, status, categoryId, trackingMode]) + useEffect(() => { + categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) + }, []) + + function categoryName(id: number) { + return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` + } + + const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All" + return ( -
-

Products

+
+
+
+

Items

+

Item master — SKU, tracking mode, category, default vendor (FR-MD-01).

+
+
+ + + UOMs + + + + Categories + + + + New Item + +
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search SKU or name…" + className="h-14 w-full pl-11 text-base" + aria-label="Search items" + /> +
+ value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}> + + + + + All categories + {categories.map((c) => ( + + {c.name} + + ))} + + + value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}> + + + + + All tracking modes + None + Batch + Serial + + + value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ + {error && ( +
{error}
+ )} + + {!error && items === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && items !== null && items.length === 0 && ( +
+ +

{hasFilters ? "No items match your search/filter." : "No items yet."}

+ {!hasFilters && ( + + + New Item + + )} +
+ )} + + {!error && items !== null && items.length > 0 && ( + <> + + + + SKU + Name + Category + Type + Tracking + Status + Actions + + + + {items.map((item) => ( + + + + {item.sku} + + + {item.name} + {categoryName(item.categoryId)} + {item.itemType} + {item.trackingMode} + + + {item.status} + + + + + + + + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )}
) } diff --git a/Frontend/erp-system/app/dashboard/products/uoms/page.tsx b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx new file mode 100644 index 0000000..70a3ebd --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx @@ -0,0 +1,134 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Plus, Ruler } from "lucide-react" + +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateUomName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Uom } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +export default function UomsPage() { + const [uoms, setUoms] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + uomsApi.list().then((res) => setUoms(res.items)).catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + async function handleCreate() { + const nextErrors = validateUomName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const uom = await uomsApi.create({ name }) + toast.success("UOM created", uom.name) + setOpen(false) + setName("") + setErrors({}) + load() + } catch (err) { + const fe = fieldErrors(err) + if (fe?.name) setErrors({ name: fe.name }) + toast.error("Could not create UOM", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+ + + +
+

Units of Measure

+

Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).

+
+
+ + + New UOM} /> + + + New UOM + e.g. EA, KG, Box-12. + + + + Name + setName(e.target.value)} placeholder="Box-12" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && uoms === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && uoms !== null && uoms.length === 0 && ( +
+ +

No UOMs yet.

+
+ )} + + {!error && uoms !== null && uoms.length > 0 && ( + + + + Name + + + + {uoms.map((u) => ( + + {u.name} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx new file mode 100644 index 0000000..941f7fe --- /dev/null +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx @@ -0,0 +1,444 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { grnsApi } from "@/lib/api/grns" +import { warehousesApi } from "@/lib/api/warehouses" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage } from "@/lib/error-map" +import { validateLine, splitSerials } from "@/lib/validations/grn" +import { cn } from "@/lib/utils" +import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn" +import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + poLineId: number | null + itemId: number | null + uomId: number | null + binId: number | null + qty: string + unitCost: string + holdStatus: HoldStatus + batchNo: string + expiryDate: string + serialNumbersText: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `egline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { + key: newKey(), + poLineId: null, + itemId: null, + uomId: null, + binId: null, + qty: "", + unitCost: "", + holdStatus: "Available", + batchNo: "", + expiryDate: "", + serialNumbersText: "", + } +} + +export default function EditGrnPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const grnId = Number(params.id) + + const [grn, setGrn] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [items, setItems] = useState(null) + const [uoms, setUoms] = useState(null) + const [bins, setBins] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [warehouseId, setWarehouseId] = useState(null) + const [lines, setLines] = useState([]) + + const [headerError, setHeaderError] = useState(null) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!Number.isFinite(grnId)) return + Promise.all([ + grnsApi.get(grnId), + warehousesApi.list(), + itemsApi.list({ pageSize: 200, status: "Active" }), + uomsApi.list(), + ]) + .then(([g, wh, it, uo]) => { + if (g.status !== "Draft") { + setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`) + setGrn(g) + return + } + setGrn(g) + setWarehouses(wh.items) + setItems(it.items) + setUoms(uo.items) + setWarehouseId(g.warehouseId) + setLines( + g.lines.map( + (l): DraftLine => ({ + key: newKey(), + poLineId: l.poLineId, + itemId: l.itemId, + uomId: l.uomId, + binId: l.binId, + qty: String(l.qty), + unitCost: String(l.unitCost), + holdStatus: l.holdStatus, + batchNo: "", + expiryDate: "", + serialNumbersText: "", + }) + ) + ) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, [grnId]) + + useEffect(() => { + if (!warehouseId) { + setBins([]) + return + } + warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([])) + }, [warehouseId]) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + function itemFor(itemId: number | null) { + return items?.find((i) => i.itemId === itemId) ?? null + } + + async function handleSubmit() { + if (!grn) return + setSubmitError(null) + setHeaderError(null) + + if (!warehouseId) { + setHeaderError("Select a warehouse.") + return + } + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validateLine({ + itemId: line.itemId, + uomId: line.uomId, + qty: line.qty, + unitCost: line.unitCost, + trackingMode: itemFor(line.itemId)?.trackingMode ?? null, + batchNo: line.batchNo, + serialNumbersText: line.serialNumbersText, + }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreateGrnLineInput[] = lines.map((l) => { + const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None" + return { + poLineId: l.poLineId, + itemId: l.itemId as number, + uomId: l.uomId as number, + binId: l.binId, + qty: Number(l.qty), + unitCost: Number(l.unitCost), + holdStatus: l.holdStatus, + batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null, + serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null, + } + }) + + setSubmitting(true) + try { + const updated = await grnsApi.update(grn.grnId, { + poId: grn.poId, + vendorId: grn.vendorId, + warehouseId: warehouseId as number, + lines: payloadLines, + }) + toast.success("GRN updated", `${updated.docNo} saved.`) + router.push(`/dashboard/receiving/grn/${updated.grnId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not update GRN", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + if (loadError) { + return ( +
+
+ + + +

Edit GRN

+
+
{loadError}
+
+ ) + } + + const loading = !grn || !warehouses || !items || !uoms + + return ( +
+
+ + + +
+

Edit {grn?.docNo ?? "GRN"}

+

Only Draft GRNs can be edited — confirming posts stock layers permanently.

+
+
+ + {loading && } + + {!loading && ( + <> +
+
+ + value={warehouseId} onValueChange={(v) => setWarehouseId(v)}> + + + + + {(warehouses ?? []).map((w) => ( + + {w.code} — {w.name} + + ))} + + +
+
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ +
+ + {lines.length > 0 && ( + + + + Item + UOM + Bin + Qty + Unit cost + Hold status + Batch / Serial + + + + + {lines.map((line) => { + const item = itemFor(line.itemId) + const errors = lineErrors[line.key] ?? {} + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + + + + value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> + + + + + {bins.map((b) => ( + + {b.code} + + ))} + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { unitCost: e.target.value })} + className="h-11 text-base" + /> + + + + value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}> + + + + + Available + On hold (inspection) + + + + + {item?.trackingMode === "Batch" && ( +
+ updateLine(line.key, { batchNo: e.target.value })} + className="h-9 text-sm" + /> + updateLine(line.key, { expiryDate: e.target.value })} + className="h-9 text-sm" + /> + +
+ )} + {item?.trackingMode === "Serial" && ( +
+